新增后台 AGC 模板管理与模板库内容寻址落库
Project CI / AI game creator shell Rust crates (push) Successful in 2m53s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Successful in 2m53s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
- 后台新增「模板管理」页:按权限查看并编辑模板名称/简介/标签/封面,支持上架与下架;路由、侧栏、tab 权限、API 客户端与 DTO 同步接入 - api-server 新增 /admin/api/agc-templates 读取与更新路由,复用现有后台鉴权与 tab 权限校验,权限不足按既有失败关闭口径拒绝 - module-assets 收敛模板库纯规则(active/inactive 投影、索引 schema 与重复项校验、ZIP 大小与摘要核对),platform-oss 承担对象存储读写、不可变对象复用与发布互斥锁 - shared-contracts 补后台模板 DTO;spacetime-module 账号存储适配保持既有 schema 不变 - AGC 壳内置模板索引与建项读取改为内容寻址对象,补模板安装、越界归档、索引不匹配等用例 - 模板库发布脚本支持定向发布与上架/下架合并、内容地址复用与锁语义,CLI 守卫用例同步;jenkins 两条客户端打包管线统一触发邮件通知 Job 并传递首装包链接 - 内置 5 个 Cocos 官方模板载荷与索引 fixture(内容地址对象,共 90 个文件) - 补「后台模板管理」里程碑与实施计划,更新模板库技术方案、开发运维文档与共享记忆 - 验证:admin-web 39 项用例与 typecheck、发布脚本 25 项用例、api-server 与 AGC 壳编译、模板库定向 Rust 用例(AGC 壳 18 项、module-assets/platform-oss 16 项、后台权限 1 项)、check:encoding / check:doc-index / check:production-ops / rustfmt 全部通过
This commit is contained in:
@@ -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"]),
|
||||
@@ -6845,6 +6847,38 @@ 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"),
|
||||
] {
|
||||
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!(
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use module_assets::template_library::{
|
||||
TemplateCover, TemplateDomainError, TemplateEdit, list_templates, prepare_template_edit,
|
||||
};
|
||||
use platform_oss::template_library::{TemplateLibraryStore, TemplateStoreError};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_contracts::admin::{
|
||||
AdminAgcTemplateCoverInput, AdminAgcTemplateListResponse, AdminAgcTemplatePayload,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crate::{
|
||||
admin::AuthenticatedAdmin, api_response::json_success_body, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
const MAX_COVER_BYTES: usize = 5 * 1024 * 1024;
|
||||
const MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024;
|
||||
const PUBLIC_BASE: &str = "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/";
|
||||
|
||||
fn fingerprint(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn decode_index(bytes: &[u8]) -> Result<Value, AppError> {
|
||||
serde_json::from_slice(bytes).map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("模板库清单格式无效")
|
||||
})
|
||||
}
|
||||
|
||||
fn public_url(key: &str) -> String {
|
||||
let mut url = reqwest::Url::parse(PUBLIC_BASE).expect("constant OSS URL");
|
||||
url.path_segments_mut()
|
||||
.expect("HTTPS path")
|
||||
.extend(key.split('/'));
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
fn snapshot(bytes: &[u8], writable: bool) -> Result<AdminAgcTemplateListResponse, AppError> {
|
||||
let entries = list_templates(&decode_index(bytes)?).map_err(map_domain_error)?;
|
||||
Ok(AdminAgcTemplateListResponse {
|
||||
revision: fingerprint(bytes),
|
||||
writable,
|
||||
templates: entries
|
||||
.into_iter()
|
||||
.map(|entry| AdminAgcTemplatePayload {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
tags: entry.tags,
|
||||
runtime: entry.runtime,
|
||||
engine: entry.engine,
|
||||
engine_version: entry.engine_version,
|
||||
template_version: entry.template_version,
|
||||
enabled: entry.enabled,
|
||||
cover_url: public_url(&entry.cover_key),
|
||||
zip_size_bytes: entry.zip_size_bytes,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn json_snapshot(context: &RequestContext, value: AdminAgcTemplateListResponse) -> Response {
|
||||
let mut response = json_success_body(Some(context), value).into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn admin_list_agc_templates(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
) -> Result<Response, AppError> {
|
||||
let store = state.template_library_store();
|
||||
let bytes = match store {
|
||||
Some(store) => store.read_index().await,
|
||||
None => TemplateLibraryStore::read_public_index().await,
|
||||
}
|
||||
.map_err(map_store_error)?;
|
||||
Ok(json_snapshot(&context, snapshot(&bytes, store.is_some())?))
|
||||
}
|
||||
|
||||
struct ValidatedCover {
|
||||
bytes: Vec<u8>,
|
||||
content_type: &'static str,
|
||||
extension: &'static str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
fn validate_cover(input: AdminAgcTemplateCoverInput) -> Result<ValidatedCover, AppError> {
|
||||
let invalid = |message| AppError::from_status(StatusCode::BAD_REQUEST).with_message(message);
|
||||
if input.data_base64.len() > MAX_COVER_BYTES.div_ceil(3) * 4 {
|
||||
return Err(invalid("封面不能超过 5 MiB"));
|
||||
}
|
||||
let bytes = STANDARD
|
||||
.decode(&input.data_base64)
|
||||
.map_err(|_| invalid("封面编码无效"))?;
|
||||
if bytes.is_empty() || bytes.len() > MAX_COVER_BYTES {
|
||||
return Err(invalid("封面内容为空或超过 5 MiB"));
|
||||
}
|
||||
let format = image::guess_format(&bytes).map_err(|_| invalid("封面不是有效图片"))?;
|
||||
let (content_type, extension) = match format {
|
||||
image::ImageFormat::Png => ("image/png", "png"),
|
||||
image::ImageFormat::Jpeg => ("image/jpeg", "jpg"),
|
||||
image::ImageFormat::WebP => ("image/webp", "webp"),
|
||||
_ => return Err(invalid("封面仅支持 PNG、JPEG 或 WebP")),
|
||||
};
|
||||
if input.content_type != content_type {
|
||||
return Err(invalid("封面格式与文件内容不一致"));
|
||||
}
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(&bytes), format);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(4096);
|
||||
limits.max_image_height = Some(4096);
|
||||
limits.max_alloc = Some(64 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
let decoded = reader
|
||||
.decode()
|
||||
.map_err(|_| invalid("封面损坏或图片尺寸过大"))?;
|
||||
let (width, height) = (decoded.width(), decoded.height());
|
||||
if width == 0 || height == 0 || u64::from(width) * u64::from(height) > 16_000_000 {
|
||||
return Err(invalid("封面最多允许 1600 万像素"));
|
||||
}
|
||||
Ok(ValidatedCover {
|
||||
bytes,
|
||||
content_type,
|
||||
extension,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn document_bytes(value: &Value) -> Result<Vec<u8>, AppError> {
|
||||
let mut bytes = serde_json::to_vec_pretty(value)
|
||||
.map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?;
|
||||
bytes.push(b'\n');
|
||||
if bytes.len() > MAX_DOCUMENT_BYTES {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("模板库元数据超过大小限制")
|
||||
);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn check_revision(bytes: &[u8], expected: &str) -> Result<(), AppError> {
|
||||
if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("模板库版本标识无效")
|
||||
);
|
||||
}
|
||||
if fingerprint(bytes) != expected.to_ascii_lowercase() {
|
||||
return Err(AppError::from_status(StatusCode::CONFLICT)
|
||||
.with_code("TEMPLATE_LIBRARY_CONFLICT")
|
||||
.with_message("模板库已更新,请刷新后重新编辑"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn admin_update_agc_template(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Path(id): Path<String>,
|
||||
Json(mut input): Json<AdminUpdateAgcTemplateRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
let store = state.template_library_store().cloned().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("模板管理未配置可用的存储凭据,当前仅支持查看")
|
||||
})?;
|
||||
let cover = match input.cover.take() {
|
||||
Some(cover) => Some(
|
||||
tokio::task::spawn_blocking(move || validate_cover(cover))
|
||||
.await
|
||||
.map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))??,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// 接受后的有限写入由独立任务持有,HTTP 断连不能在清单 PUT 在途时提前解锁。
|
||||
let result = tokio::spawn(async move { update_template(store, id, input, cover).await })
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("模板保存结果需要核对,请刷新列表;若发布锁仍被占用请联系运维")
|
||||
})??;
|
||||
Ok(json_snapshot(&context, result))
|
||||
}
|
||||
|
||||
async fn update_template(
|
||||
store: TemplateLibraryStore,
|
||||
id: String,
|
||||
input: AdminUpdateAgcTemplateRequest,
|
||||
cover: Option<ValidatedCover>,
|
||||
) -> Result<AdminAgcTemplateListResponse, AppError> {
|
||||
let mut session = store
|
||||
.begin_publish(uuid::Uuid::new_v4().to_string())
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
let outcome = async {
|
||||
let current = session.read_index().await.map_err(map_store_error)?;
|
||||
check_revision(¤t, &input.expected_revision)?;
|
||||
let index = decode_index(¤t)?;
|
||||
let entry = list_templates(&index)
|
||||
.map_err(map_domain_error)?
|
||||
.into_iter()
|
||||
.find(|entry| entry.id == id)
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("模板不存在")
|
||||
})?;
|
||||
let metadata_bytes = session
|
||||
.read_object(&entry.metadata_key, MAX_DOCUMENT_BYTES)
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
let metadata = decode_index(&metadata_bytes)?;
|
||||
let cover_reference = cover.as_ref().map(|cover| {
|
||||
let sha256 = fingerprint(&cover.bytes);
|
||||
TemplateCover {
|
||||
key: format!(
|
||||
"templates/v1/{id}/sha256/{sha256}/cover.{}",
|
||||
cover.extension
|
||||
),
|
||||
sha256,
|
||||
width: cover.width,
|
||||
height: cover.height,
|
||||
}
|
||||
});
|
||||
let updated_at = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?;
|
||||
let mut prepared = prepare_template_edit(
|
||||
index,
|
||||
metadata,
|
||||
&id,
|
||||
TemplateEdit {
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
tags: input.tags,
|
||||
enabled: input.enabled,
|
||||
},
|
||||
cover_reference.clone(),
|
||||
&updated_at,
|
||||
)
|
||||
.map_err(map_domain_error)?;
|
||||
let metadata_bytes = document_bytes(&prepared.metadata)?;
|
||||
let metadata_key = format!(
|
||||
"templates/v1/{id}/sha256/{}/template.json",
|
||||
fingerprint(&metadata_bytes)
|
||||
);
|
||||
prepared
|
||||
.set_metadata_key(&id, &metadata_key)
|
||||
.map_err(map_domain_error)?;
|
||||
let next_index_bytes = document_bytes(&prepared.index)?;
|
||||
if let (Some(cover), Some(reference)) = (cover, cover_reference) {
|
||||
session
|
||||
.put_immutable(&reference.key, cover.bytes, cover.content_type)
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
}
|
||||
session
|
||||
.put_immutable(&metadata_key, metadata_bytes, "application/json")
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
session
|
||||
.commit_index(next_index_bytes.clone())
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
snapshot(&next_index_bytes, true)
|
||||
}
|
||||
.await;
|
||||
let released = session.finish().await.map_err(map_store_error);
|
||||
match (outcome, released) {
|
||||
(Ok(result), Ok(())) => Ok(result),
|
||||
(Err(error), Ok(())) => Err(error),
|
||||
(_, Err(error)) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_domain_error(error: TemplateDomainError) -> AppError {
|
||||
match error {
|
||||
TemplateDomainError::InvalidEdit(message) => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
|
||||
}
|
||||
TemplateDomainError::NotFound => {
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("模板不存在")
|
||||
}
|
||||
_ => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("模板库元数据无效,未进行修改"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_store_error(error: TemplateStoreError) -> AppError {
|
||||
let (status, code) = match error {
|
||||
TemplateStoreError::NotFound => (StatusCode::NOT_FOUND, "TEMPLATE_LIBRARY_NOT_FOUND"),
|
||||
TemplateStoreError::Busy => (StatusCode::CONFLICT, "TEMPLATE_LIBRARY_BUSY"),
|
||||
TemplateStoreError::Uncertain => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"TEMPLATE_LIBRARY_UNCERTAIN",
|
||||
),
|
||||
_ => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"TEMPLATE_LIBRARY_UNAVAILABLE",
|
||||
),
|
||||
};
|
||||
AppError::from_status(status)
|
||||
.with_code(code)
|
||||
.with_message(error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png_input() -> AdminAgcTemplateCoverInput {
|
||||
let image = image::DynamicImage::new_rgb8(4, 3);
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||||
.expect("PNG");
|
||||
AdminAgcTemplateCoverInput {
|
||||
content_type: "image/png".to_string(),
|
||||
data_base64: STANDARD.encode(bytes.into_inner()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_cover_validates_actual_bytes_and_dimensions() {
|
||||
let cover = validate_cover(png_input()).expect("valid PNG");
|
||||
assert_eq!((cover.width, cover.height, cover.extension), (4, 3, "png"));
|
||||
let mut mismatched = png_input();
|
||||
mismatched.content_type = "image/jpeg".to_string();
|
||||
assert_eq!(
|
||||
validate_cover(mismatched)
|
||||
.err()
|
||||
.expect("mismatch")
|
||||
.status_code(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
let svg = AdminAgcTemplateCoverInput {
|
||||
content_type: "image/svg+xml".to_string(),
|
||||
data_base64: STANDARD.encode(b"<svg/>"),
|
||||
};
|
||||
assert!(validate_cover(svg).is_err());
|
||||
let mut truncated = png_input();
|
||||
truncated.data_base64 = STANDARD.encode([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
assert!(validate_cover(truncated).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_snapshot_lists_both_groups_and_uses_trusted_cover_urls() {
|
||||
let mut index: Value = serde_json::from_slice(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../../apps/ai-game-creator-shell/src-tauri/tests/fixtures/agc-template-library-index.json"
|
||||
))).expect("published fixture");
|
||||
let entries = index["templates"].as_array_mut().expect("templates");
|
||||
let count = entries.len();
|
||||
let hidden = entries.remove(0);
|
||||
let hidden_id = hidden["id"].as_str().expect("id").to_string();
|
||||
index["inactiveTemplates"] = serde_json::json!([hidden]);
|
||||
let bytes = serde_json::to_vec(&index).expect("index bytes");
|
||||
let result = snapshot(&bytes, false).expect("snapshot");
|
||||
assert_eq!(result.revision, fingerprint(&bytes));
|
||||
assert!(!result.writable);
|
||||
assert_eq!(result.templates.len(), count);
|
||||
assert!(
|
||||
!result
|
||||
.templates
|
||||
.iter()
|
||||
.find(|entry| entry.id == hidden_id)
|
||||
.expect("hidden")
|
||||
.enabled
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.templates
|
||||
.iter()
|
||||
.all(|entry| entry.cover_url.starts_with(PUBLIC_BASE))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_cover_size_and_dimension_limits_reject_before_publication() {
|
||||
let oversized = AdminAgcTemplateCoverInput {
|
||||
content_type: "image/png".to_string(),
|
||||
data_base64: "A".repeat(MAX_COVER_BYTES.div_ceil(3) * 4 + 4),
|
||||
};
|
||||
assert!(validate_cover(oversized).is_err());
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
image::DynamicImage::new_rgb8(4097, 1)
|
||||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||||
.expect("wide PNG");
|
||||
assert!(
|
||||
validate_cover(AdminAgcTemplateCoverInput {
|
||||
content_type: "image/png".to_string(),
|
||||
data_base64: STANDARD.encode(bytes.into_inner()),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_edit_requires_the_exact_snapshot_revision() {
|
||||
let bytes = br#"{"templates":[]}"#;
|
||||
assert!(check_revision(bytes, &fingerprint(bytes)).is_ok());
|
||||
assert_eq!(
|
||||
check_revision(bytes, &"0".repeat(64))
|
||||
.expect_err("stale")
|
||||
.status_code(),
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
assert_eq!(
|
||||
check_revision(bytes, "invalid")
|
||||
.expect_err("invalid")
|
||||
.status_code(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,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>,
|
||||
@@ -491,6 +494,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,
|
||||
@@ -1156,6 +1161,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"])
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,6 +47,15 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/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-models",
|
||||
get(crate::agc_models::admin_get_agc_models)
|
||||
@@ -217,6 +226,8 @@ mod route_contract_tests {
|
||||
"/admin/api/project-snapshots/{user_id}/{project_id}/download",
|
||||
&["GET"],
|
||||
),
|
||||
("/admin/api/agc-templates", &["GET"]),
|
||||
("/admin/api/agc-templates/{id}", &["PUT"]),
|
||||
("/admin/api/agc-models", &["GET", "PUT"]),
|
||||
("/admin/api/accounts", &["GET", "POST"]),
|
||||
("/admin/api/accounts/{account_id}", &["PUT"]),
|
||||
|
||||
@@ -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),
|
||||
@@ -1374,6 +1378,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
|
||||
}
|
||||
@@ -2375,6 +2383,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> {
|
||||
@@ -2722,6 +2771,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";
|
||||
@@ -2732,6 +2797,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(),
|
||||
|
||||
Reference in New Issue
Block a user