From e7309ad71b80433a1762a07f78cf018b16f94bcb Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:15:31 +0800 Subject: [PATCH] =?UTF-8?q?server-rs=20=E5=BF=AB=E7=85=A7=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E5=8F=AF=E6=B5=8B=E5=8C=96=E4=B8=8E=20Provider=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project_snapshots 抽出 plan_project_snapshot_reclamation 纯函数并新增 3 条用例:只回收上一版登记过的对象、上一版清单读不到时一张都不删、单轮回收有硬上限。 - platform-llm / platform-oss / platform-wechat / platform-audio / platform-hyper3d / platform-image / platform-matting / platform-speech 的配置类型改为手写脱敏 Debug,只输出枚举、数值、有界标识与 占位。 --- .../api-server/src/project_snapshots.rs | 162 ++++++++++++++++-- server-rs/crates/platform-audio/src/types.rs | 34 +++- .../crates/platform-hyper3d/src/types.rs | 34 +++- .../platform-image/src/vector_engine/types.rs | 36 +++- server-rs/crates/platform-llm/src/lib.rs | 57 +++++- server-rs/crates/platform-matting/src/lib.rs | 37 +++- server-rs/crates/platform-oss/src/lib.rs | 59 ++++++- server-rs/crates/platform-speech/src/lib.rs | 52 +++++- server-rs/crates/platform-wechat/src/pay.rs | 75 +++++++- .../platform-wechat/src/subscribe_message.rs | 48 +++++- 10 files changed, 569 insertions(+), 25 deletions(-) diff --git a/server-rs/crates/api-server/src/project_snapshots.rs b/server-rs/crates/api-server/src/project_snapshots.rs index 6125328a7..e49be367e 100644 --- a/server-rs/crates/api-server/src/project_snapshots.rs +++ b/server-rs/crates/api-server/src/project_snapshots.rs @@ -291,21 +291,31 @@ async fn read_project_snapshot_manifest( } } -/// 回收不再被当前清单引用、且确实由上一版清单登记过的对象。 +/// 一轮清单写入要回收哪些对象。 +pub(crate) struct ProjectSnapshotReclamationPlan { + /// 确实由上一版清单登记、且当前清单已不再引用的对象键。 + pub(crate) object_keys: Vec, + /// 因为单轮上限被推迟到下一次清单写入的条数。 + 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::>(); - 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); + } } diff --git a/server-rs/crates/platform-audio/src/types.rs b/server-rs/crates/platform-audio/src/types.rs index 65b647d9b..ff896fb9e 100644 --- a/server-rs/crates/platform-audio/src/types.rs +++ b/server-rs/crates/platform-audio/src/types.rs @@ -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", &"") + .field("api_key", &"") + .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(""), "{output}"); + assert!(output.contains("12345"), "{output}"); + } +} + #[derive(Clone, Debug)] pub struct DownloadedAudio { pub bytes: Vec, diff --git a/server-rs/crates/platform-hyper3d/src/types.rs b/server-rs/crates/platform-hyper3d/src/types.rs index 50b2b93b7..aa6067ffc 100644 --- a/server-rs/crates/platform-hyper3d/src/types.rs +++ b/server-rs/crates/platform-hyper3d/src/types.rs @@ -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", &"") + .field("api_key", &"") + .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(""), "{output}"); + assert!(output.contains("3600000"), "{output}"); + } +} + #[derive(Clone, Debug)] pub(crate) struct DecodedImageDataUrl { pub(crate) bytes: Vec, diff --git a/server-rs/crates/platform-image/src/vector_engine/types.rs b/server-rs/crates/platform-image/src/vector_engine/types.rs index 77fbd19f9..9fbf1871a 100644 --- a/server-rs/crates/platform-image/src/vector_engine/types.rs +++ b/server-rs/crates/platform-image/src/vector_engine/types.rs @@ -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, } +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", &"") + .field("api_key", &"") + .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(""), "{output}"); + assert!(output.contains("67890"), "{output}"); + } +} + #[derive(Clone, Debug)] pub struct GeneratedImages { pub task_id: String, diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 5f7180d33..757461519 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -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", &"") + .field("api_key", &"") + .field("model", &"") + .field("raw_log_dir", &"") + .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(""), "{output}"); + assert!(output.contains("OpenAiCompatible"), "{output}"); + assert!(output.contains("30000"), "{output}"); + } +} diff --git a/server-rs/crates/platform-matting/src/lib.rs b/server-rs/crates/platform-matting/src/lib.rs index b4f78d192..d2e291ece 100644 --- a/server-rs/crates/platform-matting/src/lib.rs +++ b/server-rs/crates/platform-matting/src/lib.rs @@ -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", &"") + .field("access_key_id", &"") + .field("access_key_secret", &"") + .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(""), "{output}"); + assert!(output.contains("request_timeout_ms"), "{output}"); + } +} diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 4cce2d31a..f3c829e84 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -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", &"") + .field("endpoint", &"") + .field("access_key_id", &"") + .field("access_key_secret", &"") + .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(""), "{output}"); + assert!(output.contains("3600"), "{output}"); + assert!(output.contains("204"), "{output}"); + } +} diff --git a/server-rs/crates/platform-speech/src/lib.rs b/server-rs/crates/platform-speech/src/lib.rs index 9d9ebfdd8..f89a9e004 100644 --- a/server-rs/crates/platform-speech/src/lib.rs +++ b/server-rs/crates/platform-speech/src/lib.rs @@ -64,7 +64,7 @@ const EVENT_TTS_SUBTITLE: i32 = 353; pub type SpeechWsStream = WebSocketStream>; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct VolcengineSpeechConfig { pub api_key: Option, pub app_id: Option, @@ -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(|_| "")) + .field("app_id", &self.app_id) + .field( + "access_key", + &self.access_key.as_ref().map(|_| ""), + ) + .field("asr_resource_id", &self.asr_resource_id) + .field("tts_resource_id", &self.tts_resource_id) + .field("asr_ws_url", &"") + .field("tts_bidirection_ws_url", &"") + .field("tts_sse_url", &"") + .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(""), "{output}"); + assert!(output.contains("speech-app-id"), "{output}"); + assert!(output.contains("volc.bigasr.sauc.duration"), "{output}"); + } +} diff --git a/server-rs/crates/platform-wechat/src/pay.rs b/server-rs/crates/platform-wechat/src/pay.rs index ac8db5f1f..d3d862883 100644 --- a/server-rs/crates/platform-wechat/src/pay.rs +++ b/server-rs/crates/platform-wechat/src/pay.rs @@ -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(|_| ""), + ) + .field( + "private_key_path", + &self.private_key_path.as_ref().map(|_| ""), + ) + .field( + "platform_public_key_pem", + &self.platform_public_key_pem.as_ref().map(|_| ""), + ) + .field( + "platform_public_key_path", + &self.platform_public_key_path.as_ref().map(|_| ""), + ) + .field("platform_serial_no", &self.platform_serial_no) + .field( + "api_v3_key", + &self.api_v3_key.as_ref().map(|_| ""), + ) + .field( + "notify_url", + &self.notify_url.as_ref().map(|_| ""), + ) + .field("jsapi_endpoint", &"") + .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(""), "{output}"); + assert!(output.contains("1900000001"), "{output}"); + assert!(output.contains("PLATFORM-SERIAL"), "{output}"); + } +} diff --git a/server-rs/crates/platform-wechat/src/subscribe_message.rs b/server-rs/crates/platform-wechat/src/subscribe_message.rs index 79dd2edea..6738f05cb 100644 --- a/server-rs/crates/platform-wechat/src/subscribe_message.rs +++ b/server-rs/crates/platform-wechat/src/subscribe_message.rs @@ -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, pub app_secret: Option, @@ -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(|_| ""), + ) + .field("stable_access_token_endpoint", &"") + .field("virtual_payment_query_order_endpoint", &"") + .field( + "virtual_payment_notify_provide_goods_endpoint", + &"", + ) + .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(""), "{output}"); + assert!(output.contains("wx-app-id"), "{output}"); + } +}