server-rs 快照回收可测化与 Provider 配置脱敏
- project_snapshots 抽出 plan_project_snapshot_reclamation 纯函数并新增 3 条用例:只回收上一版登记过的对象、上一版清单读不到时一张都不删、单轮回收有硬上限。 - platform-llm / platform-oss / platform-wechat / platform-audio / platform-hyper3d / platform-image / platform-matting / platform-speech 的配置类型改为手写脱敏 Debug,只输出枚举、数值、有界标识与 <redacted> 占位。
This commit is contained in:
@@ -291,21 +291,31 @@ async fn read_project_snapshot_manifest(
|
||||
}
|
||||
}
|
||||
|
||||
/// 回收不再被当前清单引用、且确实由上一版清单登记过的对象。
|
||||
/// 一轮清单写入要回收哪些对象。
|
||||
pub(crate) struct ProjectSnapshotReclamationPlan {
|
||||
/// 确实由上一版清单登记、且当前清单已不再引用的对象键。
|
||||
pub(crate) object_keys: Vec<String>,
|
||||
/// 因为单轮上限被推迟到下一次清单写入的条数。
|
||||
pub(crate) deferred: usize,
|
||||
}
|
||||
|
||||
/// 算出这一轮清单写入要回收哪些对象键。
|
||||
///
|
||||
/// 只按上一版清单里的 `(路径, 字节数, 摘要)` 反推出确定的对象键,不做 LIST,
|
||||
/// 因此不可能误删其它项目或其它功能的对象。任何单个删除失败都只记日志:
|
||||
/// 清单已经写入成功,回收失败不影响本次同步的语义,下一次写入会再试。
|
||||
async fn reclaim_unreferenced_objects(
|
||||
state: &AppState,
|
||||
oss: &platform_oss::OssClient,
|
||||
/// 计划完全由**上一版清单**反推:`(路径, 字节数, 摘要)` 三项都不在当前清单里,才算这个对象
|
||||
/// 已经退役。上一版清单缺失(读不到 / 解析失败)时返回空计划,绝不根据"当前清单里没有"就删
|
||||
/// —— 那是 fail-closed 的分界线:不确定就不删。
|
||||
fn plan_project_snapshot_reclamation(
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
previous: Option<&AgcProjectSnapshotManifestRequest>,
|
||||
next: &AgcProjectSnapshotManifestRequest,
|
||||
) {
|
||||
) -> ProjectSnapshotReclamationPlan {
|
||||
let mut plan = ProjectSnapshotReclamationPlan {
|
||||
object_keys: Vec::new(),
|
||||
deferred: 0,
|
||||
};
|
||||
let Some(previous) = previous else {
|
||||
return;
|
||||
return plan;
|
||||
};
|
||||
let retained = next
|
||||
.files
|
||||
@@ -318,8 +328,6 @@ async fn reclaim_unreferenced_objects(
|
||||
)
|
||||
})
|
||||
.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(),
|
||||
@@ -328,9 +336,9 @@ async fn reclaim_unreferenced_objects(
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
if reclaimed >= MAX_OBJECTS_RECLAIMED_PER_MANIFEST {
|
||||
if plan.object_keys.len() >= MAX_OBJECTS_RECLAIMED_PER_MANIFEST {
|
||||
// 剩下的留给下一次清单写入继续回收,不在这里无限删除。
|
||||
skipped += 1;
|
||||
plan.deferred += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(digest) = file.checksum.strip_prefix("fnv1a64:") else {
|
||||
@@ -346,6 +354,31 @@ async fn reclaim_unreferenced_objects(
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
plan.object_keys.push(object_key);
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
/// 回收不再被当前清单引用、且确实由上一版清单登记过的对象。
|
||||
///
|
||||
/// 只按上一版清单里的 `(路径, 字节数, 摘要)` 反推出确定的对象键,不做 LIST,
|
||||
/// 因此不可能误删其它项目或其它功能的对象。任何单个删除失败都只记日志:
|
||||
/// 清单已经写入成功,回收失败不影响本次同步的语义,下一次写入会再试。
|
||||
async fn reclaim_unreferenced_objects(
|
||||
state: &AppState,
|
||||
oss: &platform_oss::OssClient,
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
previous: Option<&AgcProjectSnapshotManifestRequest>,
|
||||
next: &AgcProjectSnapshotManifestRequest,
|
||||
) {
|
||||
let plan = plan_project_snapshot_reclamation(channel, user_id, previous, next);
|
||||
if plan.object_keys.is_empty() && plan.deferred == 0 {
|
||||
return;
|
||||
}
|
||||
let mut reclaimed = 0_usize;
|
||||
let mut failed = 0_usize;
|
||||
for object_key in plan.object_keys {
|
||||
match oss
|
||||
.delete_object(
|
||||
state.editor_oss_http_client(),
|
||||
@@ -357,15 +390,20 @@ async fn reclaim_unreferenced_objects(
|
||||
{
|
||||
Ok(()) => reclaimed += 1,
|
||||
Err(error) => {
|
||||
failed += 1;
|
||||
warn!(object_key = %object_key, error = %error, "项目快照旧版本对象回收失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
if reclaimed > 0 || skipped > 0 {
|
||||
if reclaimed > 0 || failed > 0 || plan.deferred > 0 {
|
||||
let project_id = previous
|
||||
.map(|manifest| manifest.project_id.as_str())
|
||||
.unwrap_or_default();
|
||||
info!(
|
||||
project_id = %previous.project_id,
|
||||
project_id = %project_id,
|
||||
reclaimed,
|
||||
skipped,
|
||||
failed,
|
||||
deferred = plan.deferred,
|
||||
"项目快照旧版本对象回收完成"
|
||||
);
|
||||
}
|
||||
@@ -606,4 +644,96 @@ mod tests {
|
||||
assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
fn reclaim_object_key(
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
file: &AgcProjectSnapshotManifestFile,
|
||||
) -> String {
|
||||
agc_project_snapshot_file_object_key(
|
||||
channel,
|
||||
user_id,
|
||||
project_id,
|
||||
file.size_bytes,
|
||||
file.checksum
|
||||
.strip_prefix("fnv1a64:")
|
||||
.expect("测试夹具固定使用 fnv1a64 摘要"),
|
||||
&file.relative_path,
|
||||
)
|
||||
.expect("合法条目必须能推出对象键")
|
||||
}
|
||||
|
||||
/// 回收只认上一版清单登记过的对象:改内容与被删除的路径回收,未变动的路径一个都不动。
|
||||
#[test]
|
||||
fn project_snapshot_reclamation_retires_only_objects_the_previous_manifest_registered() {
|
||||
const CHANNEL: &str = "agc-dev";
|
||||
const USER: &str = "user-reclaim-fixture";
|
||||
|
||||
let previous = manifest(vec![
|
||||
manifest_file("game/index.html", 10),
|
||||
manifest_file("assets/old.png", 20),
|
||||
manifest_file("assets/keep.png", 30),
|
||||
]);
|
||||
// 新清单里 index.html 换了内容(旧对象成为孤儿)、old.png 被删除,只有 keep.png 原样留下。
|
||||
let next = manifest(vec![manifest_file("assets/keep.png", 30), {
|
||||
let mut changed = manifest_file("game/index.html", 11);
|
||||
changed.checksum = "fnv1a64:fedcba9876543210".to_string();
|
||||
changed
|
||||
}]);
|
||||
|
||||
let plan = plan_project_snapshot_reclamation(CHANNEL, USER, Some(&previous), &next);
|
||||
|
||||
let project_id = previous.project_id.as_str();
|
||||
assert_eq!(
|
||||
plan.object_keys,
|
||||
vec![
|
||||
reclaim_object_key(CHANNEL, USER, project_id, &previous.files[0]),
|
||||
reclaim_object_key(CHANNEL, USER, project_id, &previous.files[1]),
|
||||
],
|
||||
"只有上一版登记、且当前清单不再引用的对象可以进入回收计划"
|
||||
);
|
||||
assert!(
|
||||
!plan.object_keys.contains(&reclaim_object_key(
|
||||
CHANNEL,
|
||||
USER,
|
||||
project_id,
|
||||
&previous.files[2],
|
||||
)),
|
||||
"内容未变动的对象不能被回收"
|
||||
);
|
||||
assert_eq!(plan.deferred, 0);
|
||||
}
|
||||
|
||||
/// 上一版清单读不到 / 解析失败时一张都不删:这是 fail-closed 的分界线。
|
||||
#[test]
|
||||
fn project_snapshot_reclamation_deletes_nothing_without_a_readable_previous_manifest() {
|
||||
let next = manifest(vec![manifest_file("game/index.html", 10)]);
|
||||
|
||||
let plan = plan_project_snapshot_reclamation("agc-dev", "user-1", None, &next);
|
||||
|
||||
assert!(plan.object_keys.is_empty(), "没有上一版清单就没有回收依据");
|
||||
assert_eq!(plan.deferred, 0);
|
||||
}
|
||||
|
||||
/// 单轮回收有上限,多出来的留到下一次清单写入,既不无限删除也不静默丢弃。
|
||||
#[test]
|
||||
fn project_snapshot_reclamation_is_bounded_per_manifest_write() {
|
||||
let deferred = 3_usize;
|
||||
let previous = manifest(
|
||||
(0..MAX_OBJECTS_RECLAIMED_PER_MANIFEST + deferred)
|
||||
.map(|index| manifest_file(&format!("game/file-{index}.txt"), index as u64 + 1))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let plan = plan_project_snapshot_reclamation(
|
||||
"agc-dev",
|
||||
"user-1",
|
||||
Some(&previous),
|
||||
&manifest(Vec::new()),
|
||||
);
|
||||
|
||||
assert_eq!(plan.object_keys.len(), MAX_OBJECTS_RECLAIMED_PER_MANIFEST);
|
||||
assert_eq!(plan.deferred, deferred);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +58,45 @@ pub struct AudioTaskResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone)]
|
||||
pub struct VectorEngineAudioSettings {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for VectorEngineAudioSettings {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// 与 `AppConfig` 同口径:只输出数值与脱敏占位,URL 与凭据不进入日志。
|
||||
f.debug_struct("VectorEngineAudioSettings")
|
||||
.field("base_url", &"<redacted>")
|
||||
.field("api_key", &"<redacted>")
|
||||
.field("request_timeout_ms", &self.request_timeout_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vector_engine_audio_settings_debug_tests {
|
||||
use super::VectorEngineAudioSettings;
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_url_and_api_key() {
|
||||
const SENTINEL: &str = "ISSUE_AUDIO_SETTINGS_DEBUG_SENTINEL";
|
||||
let settings = VectorEngineAudioSettings {
|
||||
base_url: format!("https://user:{SENTINEL}@audio.invalid/v1"),
|
||||
api_key: SENTINEL.to_string(),
|
||||
request_timeout_ms: 12_345,
|
||||
};
|
||||
|
||||
let output = format!("{settings:?}");
|
||||
|
||||
assert!(!output.contains(SENTINEL), "{output}");
|
||||
assert!(output.contains("<redacted>"), "{output}");
|
||||
assert!(output.contains("12345"), "{output}");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DownloadedAudio {
|
||||
pub bytes: Vec<u8>,
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone)]
|
||||
pub struct Hyper3dSettings {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Hyper3dSettings {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// 与 `AppConfig` 同口径:只输出数值与脱敏占位,URL 与凭据不进入日志。
|
||||
f.debug_struct("Hyper3dSettings")
|
||||
.field("base_url", &"<redacted>")
|
||||
.field("api_key", &"<redacted>")
|
||||
.field("request_timeout_ms", &self.request_timeout_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hyper3d_settings_debug_tests {
|
||||
use super::Hyper3dSettings;
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_url_and_api_key() {
|
||||
const SENTINEL: &str = "ISSUE_HYPER3D_SETTINGS_DEBUG_SENTINEL";
|
||||
let settings = Hyper3dSettings {
|
||||
base_url: format!("https://user:{SENTINEL}@hyper3d.invalid/v1"),
|
||||
api_key: SENTINEL.to_string(),
|
||||
request_timeout_ms: 3_600_000,
|
||||
};
|
||||
|
||||
let output = format!("{settings:?}");
|
||||
|
||||
assert!(!output.contains(SENTINEL), "{output}");
|
||||
assert!(output.contains("<redacted>"), "{output}");
|
||||
assert!(output.contains("3600000"), "{output}");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DecodedImageDataUrl {
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::audit::PlatformImageFailureAudit;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone)]
|
||||
pub struct VectorEngineImageSettings {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
@@ -8,6 +8,40 @@ pub struct VectorEngineImageSettings {
|
||||
pub request_deadline: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for VectorEngineImageSettings {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// 与 `AppConfig` 同口径:只输出数值/布尔与脱敏占位,URL 与凭据不进入日志。
|
||||
f.debug_struct("VectorEngineImageSettings")
|
||||
.field("base_url", &"<redacted>")
|
||||
.field("api_key", &"<redacted>")
|
||||
.field("request_timeout_ms", &self.request_timeout_ms)
|
||||
.field("request_deadline_present", &self.request_deadline.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vector_engine_image_settings_debug_tests {
|
||||
use super::VectorEngineImageSettings;
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_url_and_api_key() {
|
||||
const SENTINEL: &str = "ISSUE_IMAGE_SETTINGS_DEBUG_SENTINEL";
|
||||
let settings = VectorEngineImageSettings {
|
||||
base_url: format!("https://user:{SENTINEL}@image.invalid/v1"),
|
||||
api_key: SENTINEL.to_string(),
|
||||
request_timeout_ms: 67_890,
|
||||
request_deadline: None,
|
||||
};
|
||||
|
||||
let output = format!("{settings:?}");
|
||||
|
||||
assert!(!output.contains(SENTINEL), "{output}");
|
||||
assert!(output.contains("<redacted>"), "{output}");
|
||||
assert!(output.contains("67890"), "{output}");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeneratedImages {
|
||||
pub task_id: String,
|
||||
|
||||
@@ -66,7 +66,7 @@ pub enum OpenAiChatTokenBudgetField {
|
||||
}
|
||||
|
||||
// 统一收口文本模型网关配置,避免 api-server 和业务模块各自重复解析环境变量。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct LlmConfig {
|
||||
provider: LlmProvider,
|
||||
base_url: String,
|
||||
@@ -82,6 +82,33 @@ pub struct LlmConfig {
|
||||
openai_chat_token_budget_field: OpenAiChatTokenBudgetField,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LlmConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// 与 `AppConfig` 同口径:只输出枚举、数值、布尔与脱敏占位;
|
||||
// `base_url` / `api_key` / `model` / `raw_log_dir` 都是自由字符串,不进入日志。
|
||||
f.debug_struct("LlmConfig")
|
||||
.field("provider", &self.provider)
|
||||
.field("base_url", &"<redacted>")
|
||||
.field("api_key", &"<redacted>")
|
||||
.field("model", &"<redacted>")
|
||||
.field("raw_log_dir", &"<redacted>")
|
||||
.field("request_timeout_ms", &self.request_timeout_ms)
|
||||
.field("max_retries", &self.max_retries)
|
||||
.field("retry_backoff_ms", &self.retry_backoff_ms)
|
||||
.field("official_fallback", &self.official_fallback)
|
||||
.field("agc_client_marker", &self.agc_client_marker)
|
||||
.field(
|
||||
"anthropic_strict_tool_support",
|
||||
&self.anthropic_strict_tool_support,
|
||||
)
|
||||
.field(
|
||||
"openai_chat_token_budget_field",
|
||||
&self.openai_chat_token_budget_field,
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// 首版只冻结当前项目已稳定使用的 system/user/assistant 三种消息角色。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -8743,3 +8770,31 @@ mod tests {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod llm_config_debug_tests {
|
||||
use super::{LlmConfig, LlmProvider};
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_credentials_urls_model_and_log_dir() {
|
||||
const SENTINEL: &str = "ISSUE_LLM_CONFIG_DEBUG_SENTINEL";
|
||||
let config = LlmConfig::new(
|
||||
LlmProvider::OpenAiCompatible,
|
||||
format!("https://user:{SENTINEL}@llm.invalid/v1"),
|
||||
SENTINEL.to_string(),
|
||||
SENTINEL.to_string(),
|
||||
30_000,
|
||||
2,
|
||||
250,
|
||||
)
|
||||
.expect("fixture config");
|
||||
|
||||
let output = format!("{config:?}");
|
||||
|
||||
assert!(!output.contains(SENTINEL), "{output}");
|
||||
assert!(!output.contains("llm.invalid"), "{output}");
|
||||
assert!(output.contains("<redacted>"), "{output}");
|
||||
assert!(output.contains("OpenAiCompatible"), "{output}");
|
||||
assert!(output.contains("30000"), "{output}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const MAX_RESULT_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
|
||||
/// 像素缓冲。与 api-server BgFilter 路径的 `EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION` 对齐。
|
||||
const MAX_DECODE_IMAGE_DIMENSION: u32 = 8192;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone)]
|
||||
pub struct MattingConfig {
|
||||
pub endpoint: String,
|
||||
pub access_key_id: String,
|
||||
@@ -53,6 +53,18 @@ pub struct MattingConfig {
|
||||
pub request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MattingConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// 与 `AppConfig` 同口径:endpoint 与 AccessKey 只输出脱敏占位。
|
||||
f.debug_struct("MattingConfig")
|
||||
.field("endpoint", &"<redacted>")
|
||||
.field("access_key_id", &"<redacted>")
|
||||
.field("access_key_secret", &"<redacted>")
|
||||
.field("request_timeout_ms", &self.request_timeout_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl MattingConfig {
|
||||
pub fn new(
|
||||
endpoint: String,
|
||||
@@ -1532,3 +1544,26 @@ mod tests {
|
||||
assert!(output.pixels().all(|pixel| pixel.0 == [10, 20, 30, 90]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod matting_config_debug_tests {
|
||||
use super::MattingConfig;
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_endpoint_and_access_keys() {
|
||||
const SENTINEL: &str = "ISSUE_MATTING_CONFIG_DEBUG_SENTINEL";
|
||||
let config = MattingConfig::new(
|
||||
format!("https://matting.invalid/api?token={SENTINEL}"),
|
||||
SENTINEL.to_string(),
|
||||
SENTINEL.to_string(),
|
||||
)
|
||||
.expect("fixture config");
|
||||
|
||||
let output = format!("{config:?}");
|
||||
|
||||
assert!(!output.contains(SENTINEL), "{output}");
|
||||
assert!(!output.contains("matting.invalid"), "{output}");
|
||||
assert!(output.contains("<redacted>"), "{output}");
|
||||
assert!(output.contains("request_timeout_ms"), "{output}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub enum LegacyAssetPrefix {
|
||||
QwenSprites,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct OssConfig {
|
||||
bucket: String,
|
||||
endpoint: String,
|
||||
@@ -85,6 +85,35 @@ pub struct OssConfig {
|
||||
default_success_action_status: u16,
|
||||
}
|
||||
|
||||
impl fmt::Debug for OssConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// 与 `AppConfig` 同口径:只输出数值与脱敏占位。
|
||||
// bucket/endpoint 都是自由字符串,AccessKey 与 Secret 更是凭据,一律不进入日志。
|
||||
f.debug_struct("OssConfig")
|
||||
.field("bucket", &"<redacted>")
|
||||
.field("endpoint", &"<redacted>")
|
||||
.field("access_key_id", &"<redacted>")
|
||||
.field("access_key_secret", &"<redacted>")
|
||||
.field(
|
||||
"default_read_expire_seconds",
|
||||
&self.default_read_expire_seconds,
|
||||
)
|
||||
.field(
|
||||
"default_post_expire_seconds",
|
||||
&self.default_post_expire_seconds,
|
||||
)
|
||||
.field(
|
||||
"default_post_max_size_bytes",
|
||||
&self.default_post_max_size_bytes,
|
||||
)
|
||||
.field(
|
||||
"default_success_action_status",
|
||||
&self.default_success_action_status,
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OssPostObjectRequest {
|
||||
pub prefix: LegacyAssetPrefix,
|
||||
@@ -4108,3 +4137,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod oss_config_debug_tests {
|
||||
use super::OssConfig;
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_bucket_endpoint_and_access_keys() {
|
||||
const SENTINEL: &str = "ISSUE_OSS_CONFIG_DEBUG_SENTINEL";
|
||||
let config = OssConfig::new(
|
||||
SENTINEL.to_string(),
|
||||
format!("https://{SENTINEL}.oss.invalid"),
|
||||
SENTINEL.to_string(),
|
||||
SENTINEL.to_string(),
|
||||
3_600,
|
||||
3_600,
|
||||
5 * 1024 * 1024,
|
||||
204,
|
||||
)
|
||||
.expect("fixture config");
|
||||
|
||||
let output = format!("{config:?}");
|
||||
|
||||
assert!(!output.contains(SENTINEL), "{output}");
|
||||
assert!(output.contains("<redacted>"), "{output}");
|
||||
assert!(output.contains("3600"), "{output}");
|
||||
assert!(output.contains("204"), "{output}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ const EVENT_TTS_SUBTITLE: i32 = 353;
|
||||
|
||||
pub type SpeechWsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct VolcengineSpeechConfig {
|
||||
pub api_key: Option<String>,
|
||||
pub app_id: Option<String>,
|
||||
@@ -77,6 +77,27 @@ pub struct VolcengineSpeechConfig {
|
||||
pub request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for VolcengineSpeechConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// 与 `AppConfig` 同口径:api_key / access_key 与各 WS/SSE 地址只输出脱敏占位,
|
||||
// app_id 与资源 id 是有界标识,保留用于排障。
|
||||
f.debug_struct("VolcengineSpeechConfig")
|
||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||
.field("app_id", &self.app_id)
|
||||
.field(
|
||||
"access_key",
|
||||
&self.access_key.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("asr_resource_id", &self.asr_resource_id)
|
||||
.field("tts_resource_id", &self.tts_resource_id)
|
||||
.field("asr_ws_url", &"<redacted>")
|
||||
.field("tts_bidirection_ws_url", &"<redacted>")
|
||||
.field("tts_sse_url", &"<redacted>")
|
||||
.field("request_timeout_ms", &self.request_timeout_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicSpeechConfig {
|
||||
@@ -1203,3 +1224,32 @@ mod tests {
|
||||
assert_eq!(body["req_params"]["audio_params"]["bit_rate"], 64_000);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod volcengine_speech_config_debug_tests {
|
||||
use super::VolcengineSpeechConfig;
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_keys_and_endpoints() {
|
||||
const SENTINEL: &str = "ISSUE_SPEECH_CONFIG_DEBUG_SENTINEL";
|
||||
let config = VolcengineSpeechConfig {
|
||||
api_key: Some(SENTINEL.to_string()),
|
||||
app_id: Some("speech-app-id".to_string()),
|
||||
access_key: Some(SENTINEL.to_string()),
|
||||
asr_resource_id: "volc.bigasr.sauc.duration".to_string(),
|
||||
tts_resource_id: "volc.service_type.10029".to_string(),
|
||||
asr_ws_url: format!("wss://speech.invalid/asr?token={SENTINEL}"),
|
||||
tts_bidirection_ws_url: format!("wss://speech.invalid/tts?token={SENTINEL}"),
|
||||
tts_sse_url: format!("https://speech.invalid/tts?token={SENTINEL}"),
|
||||
request_timeout_ms: 15_000,
|
||||
};
|
||||
|
||||
let output = format!("{config:?}");
|
||||
|
||||
assert!(!output.contains(SENTINEL), "{output}");
|
||||
assert!(!output.contains("speech.invalid"), "{output}");
|
||||
assert!(output.contains("<redacted>"), "{output}");
|
||||
assert!(output.contains("speech-app-id"), "{output}");
|
||||
assert!(output.contains("volc.bigasr.sauc.duration"), "{output}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ pub const WECHAT_VIRTUAL_PAYMENT_NOTIFY_EVENTS: [&str; 9] = [
|
||||
pub const WECHAT_PAY_REFUND_NOTIFY_EVENTS: [&str; 3] =
|
||||
["REFUND.SUCCESS", "REFUND.ABNORMAL", "REFUND.CLOSED"];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone)]
|
||||
pub struct WechatPayConfig {
|
||||
pub enabled: bool,
|
||||
pub provider: String,
|
||||
@@ -111,6 +111,46 @@ pub struct WechatPayConfig {
|
||||
pub jsapi_endpoint: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WechatPayConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// 与 `AppConfig` 同口径:凭据、私钥、路径与 URL 一律只输出脱敏占位,
|
||||
// 只保留布尔、可判空状态与商户/证书标识这类有界非密字段。
|
||||
f.debug_struct("WechatPayConfig")
|
||||
.field("enabled", &self.enabled)
|
||||
.field("provider", &self.provider)
|
||||
.field("app_id", &self.app_id)
|
||||
.field("mch_id", &self.mch_id)
|
||||
.field("merchant_serial_no", &self.merchant_serial_no)
|
||||
.field(
|
||||
"private_key_pem",
|
||||
&self.private_key_pem.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field(
|
||||
"private_key_path",
|
||||
&self.private_key_path.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field(
|
||||
"platform_public_key_pem",
|
||||
&self.platform_public_key_pem.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field(
|
||||
"platform_public_key_path",
|
||||
&self.platform_public_key_path.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("platform_serial_no", &self.platform_serial_no)
|
||||
.field(
|
||||
"api_v3_key",
|
||||
&self.api_v3_key.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field(
|
||||
"notify_url",
|
||||
&self.notify_url.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("jsapi_endpoint", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WechatPayClient {
|
||||
Disabled,
|
||||
@@ -4707,3 +4747,36 @@ LwIDAQAB
|
||||
BASE64_STANDARD.encode(encrypted)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod wechat_pay_config_debug_tests {
|
||||
use super::WechatPayConfig;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_keys_paths_and_urls() {
|
||||
const SENTINEL: &str = "ISSUE_WECHAT_PAY_CONFIG_DEBUG_SENTINEL";
|
||||
let config = WechatPayConfig {
|
||||
enabled: true,
|
||||
provider: "real".to_string(),
|
||||
app_id: Some("wx-app-id".to_string()),
|
||||
mch_id: Some("1900000001".to_string()),
|
||||
merchant_serial_no: Some("MERCHANT-SERIAL".to_string()),
|
||||
private_key_pem: Some(format!("-----BEGIN {SENTINEL}-----")),
|
||||
private_key_path: Some(PathBuf::from(format!("/tmp/{SENTINEL}"))),
|
||||
platform_public_key_pem: Some(SENTINEL.to_string()),
|
||||
platform_public_key_path: Some(PathBuf::from(format!("/tmp/{SENTINEL}"))),
|
||||
platform_serial_no: Some("PLATFORM-SERIAL".to_string()),
|
||||
api_v3_key: Some(SENTINEL.to_string()),
|
||||
notify_url: Some(format!("https://wechat.invalid/notify?token={SENTINEL}")),
|
||||
jsapi_endpoint: format!("https://wechat.invalid/jsapi?token={SENTINEL}"),
|
||||
};
|
||||
|
||||
let output = format!("{config:?}");
|
||||
|
||||
assert!(!output.contains(SENTINEL), "{output}");
|
||||
assert!(!output.contains("wechat.invalid"), "{output}");
|
||||
assert!(output.contains("<redacted>"), "{output}");
|
||||
assert!(output.contains("1900000001"), "{output}");
|
||||
assert!(output.contains("PLATFORM-SERIAL"), "{output}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI: &str = "/xpay/query_order";
|
||||
const WECHAT_ACCESS_TOKEN_REFRESH_SAFETY_MARGIN: Duration = Duration::from_secs(5 * 60);
|
||||
const WECHAT_ACCESS_TOKEN_ERROR_CODES: [i64; 3] = [40001, 40014, 42001];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct WechatConfig {
|
||||
pub app_id: Option<String>,
|
||||
pub app_secret: Option<String>,
|
||||
@@ -32,6 +32,25 @@ pub struct WechatConfig {
|
||||
pub virtual_payment_notify_provide_goods_endpoint: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WechatConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// 与 `AppConfig` 同口径:app_secret 与各 endpoint(可能带 query 凭据)只输出脱敏占位。
|
||||
f.debug_struct("WechatConfig")
|
||||
.field("app_id", &self.app_id)
|
||||
.field(
|
||||
"app_secret",
|
||||
&self.app_secret.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("stable_access_token_endpoint", &"<redacted>")
|
||||
.field("virtual_payment_query_order_endpoint", &"<redacted>")
|
||||
.field(
|
||||
"virtual_payment_notify_provide_goods_endpoint",
|
||||
&"<redacted>",
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WechatClient {
|
||||
client: Client,
|
||||
@@ -769,3 +788,30 @@ mod tests {
|
||||
String::from_utf8(bytes).expect("mock request should be UTF-8")
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod wechat_config_debug_tests {
|
||||
use super::WechatConfig;
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_app_secret_and_endpoints() {
|
||||
const SENTINEL: &str = "ISSUE_WECHAT_CONFIG_DEBUG_SENTINEL";
|
||||
let config = WechatConfig {
|
||||
app_id: Some("wx-app-id".to_string()),
|
||||
app_secret: Some(SENTINEL.to_string()),
|
||||
stable_access_token_endpoint: format!("https://wechat.invalid/token?secret={SENTINEL}"),
|
||||
virtual_payment_query_order_endpoint: format!(
|
||||
"https://wechat.invalid/query?secret={SENTINEL}"
|
||||
),
|
||||
virtual_payment_notify_provide_goods_endpoint: format!(
|
||||
"https://wechat.invalid/notify?secret={SENTINEL}"
|
||||
),
|
||||
};
|
||||
|
||||
let output = format!("{config:?}");
|
||||
|
||||
assert!(!output.contains(SENTINEL), "{output}");
|
||||
assert!(!output.contains("wechat.invalid"), "{output}");
|
||||
assert!(output.contains("<redacted>"), "{output}");
|
||||
assert!(output.contains("wx-app-id"), "{output}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user