Files
Genarrative/server-rs/crates/api-server/src/project_snapshots.rs
T
kdletters 4951b71d71
Project CI / AI game creator shell Rust crates (push) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m5s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m40s
Project CI / Repository checks (push) Successful in 5m37s
Project CI / Frontend tests (push) Successful in 6m21s
Project CI / Backend tests (push) Successful in 9m56s
Project CI / Native shell tests (push) Successful in 10m57s
Project CI / AI game creator shell web tests (push) Successful in 5m29s
项目快照按渠道分区,后台项目工程支持渠道筛选与游标分页
项目快照对象键升级为 agc/project-snapshots/v2/{channel}/{user}/{project}/,渠道取部署配置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL(缺省沿用客户端下载渠道),非法渠道在写入处失败关闭
后台新增渠道列表接口,项目工程列表与下载接受 channel,游标绑定渠道并拒绝跨渠道复用
后台项目工程用户列改为素材查询口径:昵称 + 陶泥号 + 用户详情入口,由 api-server 解析作者信息
后台项目工程改为游标分页:每页 20/50/100、上一页/下一页与当前页提示,翻页失败保留当前页
部署环境示例补充快照渠道配置,并同步运维、技术方案、里程碑与决策记录文档
2026-09-21 16:57:16 +08:00

610 lines
24 KiB
Rust

//! AGC 项目定时快照上传的服务端入口。
//!
//! 客户端只提交"某个项目里发生变化的文件内容"和"当前清单",对象键、bucket 与
//! 存储凭据都由服务端决定。写入固定在内部前缀下,客户端直传票据不覆盖该前缀。
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
use axum::{
Json,
body::Bytes,
extract::{Extension, Query, State},
http::StatusCode,
};
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::{
AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES, AGC_PROJECT_SNAPSHOT_MAX_MANIFEST_FILES,
AGC_PROJECT_SNAPSHOT_MAX_PROJECT_BYTES, AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION,
AgcProjectSnapshotFileUploadQuery, AgcProjectSnapshotFileUploadResponse,
AgcProjectSnapshotManifestRequest, AgcProjectSnapshotManifestResponse,
agc_project_snapshot_checksum, validate_agc_project_snapshot_checksum,
validate_agc_project_snapshot_project_id, validate_agc_project_snapshot_relative_path,
};
use std::{
collections::{HashMap, HashSet},
sync::{Mutex, OnceLock},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tracing::{debug, info, warn};
/// 单文件请求体上限比文件上限留一点余量,超限请求由 body limit 直接拒绝。
pub(crate) const MAX_FILE_REQUEST_BODY_BYTES: usize =
AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES as usize + 1024;
/// 清单请求体上限:条目数本身有上限,这里再给一个字节级兜底。
pub(crate) const MAX_MANIFEST_REQUEST_BODY_BYTES: usize = 32 * 1024 * 1024;
/// 清单里单条路径的字段长度上限(与契约校验口径一致)。
const MAX_MANIFEST_PATH_CHARS: usize = 1024;
/// 单个用户每小时允许的文件上传次数。进程内计数,用于抑制异常客户端,不是计费级配额。
const MAX_FILE_UPLOADS_PER_USER_PER_HOUR: usize = 3_000;
/// 单个用户每小时允许的清单写入次数。
const MAX_MANIFEST_UPLOADS_PER_USER_PER_HOUR: usize = 120;
/// 同一项目两次清单写入的最小间隔,避免异常客户端高频覆盖清单。
const MIN_MANIFEST_INTERVAL_MS: u64 = 5_000;
/// 单次清单写入最多回收多少个不再被引用的对象,避免一次请求做过量删除。
const MAX_OBJECTS_RECLAIMED_PER_MANIFEST: usize = 2_000;
/// 写入一次增量同步里的单个文件。
///
/// 对象键由字节数和内容摘要共同决定,因此"对象已存在且长度一致"就是内容已存在的
/// 充分判据;探测失败按未存在处理,PUT 本身是幂等的。
pub async fn upload_project_snapshot_file(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
Query(query): Query<AgcProjectSnapshotFileUploadQuery>,
body: Bytes,
) -> Result<Json<Value>, AppError> {
consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::File)?;
let size_bytes = validate_uploaded_file(&query, &body)?;
let digest = query
.checksum
.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,
&digest,
&query.relative_path,
)
.map_err(|error| bad_request(error.to_string()))?;
let oss = project_snapshot_oss(&state)?;
let skipped = match oss
.head_internal_object(state.editor_oss_http_client(), &object_key)
.await
{
Ok(Some(existing)) => existing.content_length == size_bytes,
// 确定不存在、或探测失败时都继续写入:PUT 幂等,宁可多传一次也不漏传。
Ok(None) | Err(_) => false,
};
if !skipped {
oss.put_internal_object(
state.editor_oss_http_client(),
OssInternalPutObjectRequest {
object_key: object_key.clone(),
content_type: Some("application/octet-stream".to_string()),
access: OssObjectAccess::Private,
metadata: Default::default(),
body: body.to_vec(),
},
)
.await
.map_err(|_| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("项目快照文件上传失败")
})?;
}
Ok(json_success_body(
Some(&ctx),
AgcProjectSnapshotFileUploadResponse {
project_id: query.project_id,
relative_path: query.relative_path,
object_key,
skipped,
checksum: query.checksum,
size_bytes,
},
))
}
fn validate_uploaded_file(
query: &AgcProjectSnapshotFileUploadQuery,
body: &[u8],
) -> Result<u64, AppError> {
validate_agc_project_snapshot_project_id(&query.project_id).map_err(bad_request)?;
validate_agc_project_snapshot_relative_path(&query.relative_path).map_err(bad_request)?;
validate_agc_project_snapshot_checksum(&query.checksum).map_err(bad_request)?;
let size_bytes =
u64::try_from(body.len()).map_err(|_| bad_request("项目快照文件长度超出可支持范围"))?;
if size_bytes > AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES {
return Err(bad_request("项目快照文件超过单文件上限"));
}
if size_bytes != query.size_bytes {
return Err(bad_request("项目快照文件长度与声明不一致"));
}
if !agc_project_snapshot_checksum(body).eq_ignore_ascii_case(&query.checksum) {
return Err(bad_request("项目快照文件内容与声明摘要不一致"));
}
Ok(size_bytes)
}
/// 覆盖写入该项目的远端清单。删除文件只在这里消失,本期不删除远端对象。
pub async fn upload_project_snapshot_manifest(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
Json(payload): Json<AgcProjectSnapshotManifestRequest>,
) -> Result<Json<Value>, AppError> {
consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::Manifest)?;
validate_manifest(&payload)?;
let user_id = auth.claims().user_id().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;
if let Some(previous) = previous.as_ref()
&& unix_millis_now().saturating_sub(previous.synced_at_ms) < MIN_MANIFEST_INTERVAL_MS
{
return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message("同一项目的项目快照写入过于频繁,请稍后重试")
.with_header("retry-after", axum::http::HeaderValue::from_static("5")));
}
let body = serde_json::to_vec(&payload)
.map_err(|error| internal(format!("序列化项目快照清单失败:{error}")))?;
let total_bytes = payload
.files
.iter()
.fold(0_u64, |total, file| total.saturating_add(file.size_bytes));
let oss = project_snapshot_oss(&state)?;
oss.put_internal_object(
state.editor_oss_http_client(),
OssInternalPutObjectRequest {
object_key: object_key.clone(),
content_type: Some("application/json".to_string()),
access: OssObjectAccess::Private,
metadata: Default::default(),
body,
},
)
.await
.map_err(|_| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("项目快照清单上传失败")
})?;
// 清单写入成功之后再回收:任何时刻远端对象集合都是当前清单的超集,
// 不会出现清单引用了刚被删掉的对象。
reclaim_unreferenced_objects(&state, oss, &channel, &user_id, previous.as_ref(), &payload)
.await;
Ok(json_success_body(
Some(&ctx),
AgcProjectSnapshotManifestResponse {
project_id: payload.project_id,
sync_revision: payload.sync_revision,
object_key,
file_count: u32::try_from(payload.files.len()).unwrap_or(u32::MAX),
total_bytes,
},
))
}
/// 单个用户在窗口内的上传计数。进程内计数,用于抑制异常客户端与失控重试;
/// 跨节点配额由"单项目累计体积上限 + 清单引用回收"保证。
#[derive(Clone, Copy, Default)]
struct ProjectSnapshotUserWindow {
started_at: Option<Instant>,
files: usize,
manifests: usize,
}
#[derive(Clone, Copy)]
enum ProjectSnapshotUploadKind {
File,
Manifest,
}
static PROJECT_SNAPSHOT_USER_WINDOWS: OnceLock<Mutex<HashMap<String, ProjectSnapshotUserWindow>>> =
OnceLock::new();
fn consume_user_upload_quota(
user_id: &str,
kind: ProjectSnapshotUploadKind,
) -> Result<(), AppError> {
let windows = PROJECT_SNAPSHOT_USER_WINDOWS.get_or_init(|| Mutex::new(HashMap::new()));
let mut windows = windows
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let window = windows.entry(user_id.to_string()).or_default();
let now = Instant::now();
let expired = window
.started_at
.is_none_or(|started_at| now.duration_since(started_at) >= Duration::from_secs(3_600));
if expired {
*window = ProjectSnapshotUserWindow {
started_at: Some(now),
..ProjectSnapshotUserWindow::default()
};
}
let (counter, limit) = match kind {
ProjectSnapshotUploadKind::File => (&mut window.files, MAX_FILE_UPLOADS_PER_USER_PER_HOUR),
ProjectSnapshotUploadKind::Manifest => (
&mut window.manifests,
MAX_MANIFEST_UPLOADS_PER_USER_PER_HOUR,
),
};
if *counter >= limit {
return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message("项目快照上传次数超出当前小时上限,请稍后重试")
.with_header("retry-after", axum::http::HeaderValue::from_static("60")));
}
*counter += 1;
Ok(())
}
fn unix_millis_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
/// 读取该项目上一版清单。只在对象不存在时返回 `None`;读取或解析失败同样返回
/// `None`,调用方据此跳过回收(fail-closed:不确定就不删)。
async fn read_project_snapshot_manifest(
state: &AppState,
object_key: &str,
) -> Option<AgcProjectSnapshotManifestRequest> {
let oss = project_snapshot_oss(state).ok()?;
match oss
.get_object(
state.editor_oss_http_client(),
OssGetObjectRequest {
object_key: object_key.to_string(),
max_bytes: MAX_MANIFEST_REQUEST_BODY_BYTES,
},
)
.await
{
Ok(bytes) => serde_json::from_slice::<AgcProjectSnapshotManifestRequest>(&bytes)
.map_err(|error| {
warn!(object_key = %object_key, error = %error, "项目快照上一版清单解析失败,本轮跳过回收");
error
})
.ok(),
Err(error) => {
debug!(object_key = %object_key, error = %error, "项目快照上一版清单不可读,本轮跳过回收");
None
}
}
}
/// 回收不再被当前清单引用、且确实由上一版清单登记过的对象。
///
/// 只按上一版清单里的 `(路径, 字节数, 摘要)` 反推出确定的对象键,不做 LIST,
/// 因此不可能误删其它项目或其它功能的对象。任何单个删除失败都只记日志:
/// 清单已经写入成功,回收失败不影响本次同步的语义,下一次写入会再试。
async fn reclaim_unreferenced_objects(
state: &AppState,
oss: &platform_oss::OssClient,
channel: &str,
user_id: &str,
previous: Option<&AgcProjectSnapshotManifestRequest>,
next: &AgcProjectSnapshotManifestRequest,
) {
let Some(previous) = previous else {
return;
};
let retained = next
.files
.iter()
.map(|file| {
(
file.relative_path.as_str(),
file.size_bytes,
file.checksum.as_str(),
)
})
.collect::<HashSet<_>>();
let mut reclaimed = 0_usize;
let mut skipped = 0_usize;
for file in &previous.files {
if retained.contains(&(
file.relative_path.as_str(),
file.size_bytes,
file.checksum.as_str(),
)) {
continue;
}
if reclaimed >= MAX_OBJECTS_RECLAIMED_PER_MANIFEST {
// 剩下的留给下一次清单写入继续回收,不在这里无限删除。
skipped += 1;
continue;
}
let Some(digest) = file.checksum.strip_prefix("fnv1a64:") else {
continue;
};
let Ok(object_key) = agc_project_snapshot_file_object_key(
channel,
user_id,
&previous.project_id,
file.size_bytes,
digest,
&file.relative_path,
) else {
continue;
};
match oss
.delete_object(
state.editor_oss_http_client(),
OssDeleteObjectRequest {
object_key: object_key.clone(),
},
)
.await
{
Ok(()) => reclaimed += 1,
Err(error) => {
warn!(object_key = %object_key, error = %error, "项目快照旧版本对象回收失败");
}
}
}
if reclaimed > 0 || skipped > 0 {
info!(
project_id = %previous.project_id,
reclaimed,
skipped,
"项目快照旧版本对象回收完成"
);
}
}
pub(crate) fn validate_manifest(
payload: &AgcProjectSnapshotManifestRequest,
) -> Result<(), AppError> {
if payload.schema_version != AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION {
return Err(bad_request("项目快照清单版本不受支持"));
}
validate_agc_project_snapshot_project_id(&payload.project_id).map_err(bad_request)?;
if payload.synced_at_ms == 0 {
return Err(bad_request("项目快照清单缺少同步时刻"));
}
if payload.project_name.as_ref().is_some_and(|name| {
name.trim().is_empty() || name.len() > 512 || name.chars().any(char::is_control)
}) {
return Err(bad_request(
"项目快照名称必须是 1 到 512 字节且不含控制字符",
));
}
if payload.files.len() > AGC_PROJECT_SNAPSHOT_MAX_MANIFEST_FILES {
return Err(bad_request("项目快照清单文件数量超过上限"));
}
let mut seen = HashSet::with_capacity(payload.files.len());
let mut paths = HashMap::new();
let mut total_bytes = 0_u64;
for file in &payload.files {
if file.relative_path.chars().count() > MAX_MANIFEST_PATH_CHARS {
return Err(bad_request("项目快照清单路径过长"));
}
validate_agc_project_snapshot_relative_path(&file.relative_path).map_err(bad_request)?;
validate_agc_project_snapshot_checksum(&file.checksum).map_err(bad_request)?;
if file.size_bytes > AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES {
return Err(bad_request("项目快照清单包含超过单文件上限的条目"));
}
if !seen.insert(file.relative_path.as_str()) {
return Err(bad_request("项目快照清单包含重复路径"));
}
let mut path = String::new();
let components = file.relative_path.split('/').collect::<Vec<_>>();
for (index, component) in components.iter().enumerate() {
if index > 0 {
path.push('/');
}
path.push_str(component);
let is_file = index + 1 == components.len();
let identity = path.to_lowercase();
if let Some((original, previous_is_file)) = paths.get(&identity) {
if original != &path || *previous_is_file || is_file {
return Err(bad_request("项目快照清单包含大小写或文件目录冲突"));
}
} else {
paths.insert(identity, (path.clone(), is_file));
}
}
total_bytes = total_bytes.saturating_add(file.size_bytes);
}
// 清单描述的是项目当前全量文件,所以这里的累计体积就是该项目的常驻占用;
// 单项目上限同时约束了远端对象回收之后的实际存储量。
if total_bytes > AGC_PROJECT_SNAPSHOT_MAX_PROJECT_BYTES {
return Err(AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE)
.with_message("项目快照累计体积超过单项目上限"));
}
Ok(())
}
pub(crate) fn project_snapshot_oss(state: &AppState) -> Result<&platform_oss::OssClient, AppError> {
state.project_snapshot_oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("AGC 项目快照 OSS 未配置")
})
}
/// 本部署的快照渠道:配置文件缺省跟随客户端下载渠道,显式配置优先。
/// 渠道名非法时失败关闭(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)
}
fn internal<E: ToString>(e: E) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::agc_project_snapshots::AgcProjectSnapshotManifestFile;
#[test]
fn project_snapshots_upload_accepts_empty_files_and_rejects_false_checksums() {
let mut query = AgcProjectSnapshotFileUploadQuery {
project_id: "project-1".into(),
relative_path: "game/empty.txt".into(),
checksum: "fnv1a64:cbf29ce484222325".into(),
size_bytes: 0,
};
assert_eq!(validate_uploaded_file(&query, b"").unwrap(), 0);
assert!(validate_uploaded_file(&query, b"x").is_err());
query.size_bytes = 1;
assert!(validate_uploaded_file(&query, b"x").is_err());
query.checksum = agc_project_snapshot_checksum(b"x");
assert_eq!(validate_uploaded_file(&query, b"x").unwrap(), 1);
}
fn manifest_file(relative_path: &str, size_bytes: u64) -> AgcProjectSnapshotManifestFile {
AgcProjectSnapshotManifestFile {
relative_path: relative_path.to_string(),
size_bytes,
checksum: "fnv1a64:0123456789abcdef".to_string(),
}
}
fn manifest(files: Vec<AgcProjectSnapshotManifestFile>) -> AgcProjectSnapshotManifestRequest {
AgcProjectSnapshotManifestRequest {
schema_version: AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION,
project_id: "gameagent-1a2b3c4d".to_string(),
sync_revision: 1,
synced_at_ms: 1_700_000_000_000,
files,
project_name: None,
pending_files: None,
}
}
#[test]
fn project_snapshot_manifest_accepts_a_bounded_payload() {
assert!(
validate_manifest(&manifest(vec![
manifest_file("game/index.html", 1024),
manifest_file("assets/Hero.png", 2048),
]))
.is_ok()
);
}
#[test]
fn project_snapshot_manifest_rejects_duplicates_and_out_of_range_values() {
assert!(
validate_manifest(&manifest(vec![
manifest_file("game/index.html", 1),
manifest_file("game/index.html", 1),
]))
.is_err(),
"重复路径会让远端清单产生歧义"
);
let mut traversal = manifest(vec![manifest_file("game/index.html", 1)]);
traversal.files[0].relative_path = "../outside.txt".to_string();
assert!(validate_manifest(&traversal).is_err());
let mut unsafe_project = manifest(vec![manifest_file("game/index.html", 1)]);
unsafe_project.project_id = "../escape".to_string();
assert!(validate_manifest(&unsafe_project).is_err());
let mut bad_checksum = manifest(vec![manifest_file("game/index.html", 1)]);
bad_checksum.files[0].checksum = "md5:not-hex".to_string();
assert!(validate_manifest(&bad_checksum).is_err());
let mut oversized = manifest(vec![manifest_file("game/index.html", 1)]);
oversized.files[0].size_bytes = AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES + 1;
assert!(validate_manifest(&oversized).is_err());
let mut stale_schema = manifest(vec![manifest_file("game/index.html", 1)]);
stale_schema.schema_version = AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION + 1;
assert!(validate_manifest(&stale_schema).is_err());
let mut missing_timestamp = manifest(vec![manifest_file("game/index.html", 1)]);
missing_timestamp.synced_at_ms = 0;
assert!(validate_manifest(&missing_timestamp).is_err());
}
#[test]
fn project_snapshot_manifest_rejects_too_many_entries() {
let files = (0..=AGC_PROJECT_SNAPSHOT_MAX_MANIFEST_FILES)
.map(|index| manifest_file(&format!("game/file-{index}.txt"), 1))
.collect::<Vec<_>>();
assert!(validate_manifest(&manifest(files)).is_err());
}
#[test]
fn project_snapshot_manifest_rejects_a_project_over_the_total_cap() {
// 单文件与条目数都在上限内,只有项目累计体积越界:这必须按 413 拒绝,
// 否则远端回收之后仍会长期占住超大配额。
let files = (0..40)
.map(|index| {
manifest_file(
&format!("assets/blob-{index}.bin"),
AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES,
)
})
.collect::<Vec<_>>();
let error = validate_manifest(&manifest(files)).expect_err("超额项目必须被拒绝");
assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[test]
fn project_snapshot_user_quota_stops_after_the_hourly_file_limit() {
let user = "user-quota-fixture";
for _ in 0..MAX_FILE_UPLOADS_PER_USER_PER_HOUR {
consume_user_upload_quota(user, ProjectSnapshotUploadKind::File)
.expect("配额内的上传必须放行");
}
let error = consume_user_upload_quota(user, ProjectSnapshotUploadKind::File)
.expect_err("超出小时上限必须被拒绝");
assert_eq!(error.status_code(), StatusCode::TOO_MANY_REQUESTS);
// 清单与文件是两条独立配额,不受文件配额影响。
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);
}
}
}