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:
kdletters
2026-09-28 14:15:31 +08:00
parent d6c939fa31
commit e7309ad71b
10 changed files with 569 additions and 25 deletions
@@ -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);
}
}
+33 -1
View File
@@ -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>,
+33 -1
View File
@@ -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,
+56 -1
View File
@@ -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}");
}
}
+36 -1
View File
@@ -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}");
}
}
+58 -1
View File
@@ -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}");
}
}
+51 -1
View File
@@ -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}");
}
}
+74 -1
View File
@@ -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}");
}
}