新增后台 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

- 后台新增「模板管理」页:按权限查看并编辑模板名称/简介/标签/封面,支持上架与下架;路由、侧栏、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:
kdletters
2026-09-21 13:50:26 +08:00
parent 6845cdcbc5
commit 02a7abeb3f
129 changed files with 21480 additions and 198 deletions
+34
View File
@@ -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(&current, &input.expected_revision)?;
let index = decode_index(&current)?;
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
);
}
}
+10
View File
@@ -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"])
{
+1
View File
@@ -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"]),
+67
View File
@@ -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(),