chore: checkpoint local workspace changes

This commit is contained in:
2026-04-23 12:45:15 +08:00
parent 3eb9390e8f
commit a6cd9afcbb
47 changed files with 2154 additions and 529 deletions

View File

@@ -17,6 +17,7 @@ rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
time = { version = "0.3", features = ["std"] }
tracing = "0.1"
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }

View File

@@ -18,6 +18,7 @@ use sha1::Sha1;
use sha2::{Digest, Sha256};
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
@@ -110,7 +111,7 @@ pub struct RefreshCookieConfig {
refresh_session_ttl_days: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SmsAuthProviderKind {
Mock,
Aliyun,
@@ -203,15 +204,16 @@ pub enum SmsProviderError {
#[derive(Debug, Deserialize)]
struct AliyunSendSmsVerifyCodeResponse {
#[serde(default)]
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
#[serde(default, rename = "Code")]
code: Option<String>,
#[serde(default)]
#[serde(default, rename = "Message")]
message: Option<String>,
#[serde(default)]
#[serde(default, rename = "RequestId")]
request_id: Option<String>,
#[serde(default)]
#[serde(default, rename = "Success")]
success: Option<bool>,
#[serde(default)]
#[serde(default, rename = "Model")]
model: Option<AliyunSendSmsVerifyCodeModel>,
}
@@ -227,13 +229,14 @@ struct AliyunSendSmsVerifyCodeModel {
#[derive(Debug, Deserialize)]
struct AliyunCheckSmsVerifyCodeResponse {
#[serde(default)]
// 校验接口同样返回首字母大写字段名,保持和发送接口一致的显式映射。
#[serde(default, rename = "Code")]
code: Option<String>,
#[serde(default)]
#[serde(default, rename = "Message")]
message: Option<String>,
#[serde(default)]
#[serde(default, rename = "Success")]
success: Option<bool>,
#[serde(default)]
#[serde(default, rename = "Model")]
model: Option<AliyunCheckSmsVerifyCodeModel>,
}
@@ -356,6 +359,13 @@ impl SmsAuthProviderKind {
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Mock => "mock",
Self::Aliyun => "aliyun",
}
}
}
impl SmsAuthConfig {
@@ -477,6 +487,13 @@ impl SmsAuthProvider {
}
}
pub fn kind(&self) -> SmsAuthProviderKind {
match self {
Self::Mock(_) => SmsAuthProviderKind::Mock,
Self::Aliyun(_) => SmsAuthProviderKind::Aliyun,
}
}
pub async fn send_code(
&self,
request: SmsSendCodeRequest,
@@ -530,11 +547,25 @@ impl AliyunSmsAuthProvider {
request: SmsSendCodeRequest,
) -> Result<SmsSendCodeResult, SmsProviderError> {
let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number);
let phone_masked = mask_phone_number(&request.national_phone_number);
let template_param = serde_json::json!({
self.config.template_param_key.clone(): "##code##",
"min": self.config.valid_time_seconds,
})
.to_string();
info!(
provider = "aliyun",
scene = request.scene.as_str(),
phone_masked = phone_masked.as_str(),
endpoint = self.config.endpoint.as_str(),
sign_name = self.config.sign_name.as_str(),
template_code = self.config.template_code.as_str(),
code_length = self.config.code_length,
valid_time_seconds = self.config.valid_time_seconds,
interval_seconds = self.config.interval_seconds,
provider_out_id = provider_out_id.as_str(),
"准备调用阿里云短信发送接口"
);
let mut query = BTreeMap::new();
query.insert("Action".to_string(), "SendSmsVerifyCode".to_string());
@@ -596,9 +627,49 @@ impl AliyunSmsAuthProvider {
.send()
.await
.map_err(|error| SmsProviderError::Upstream(format!("短信验证码发送失败:{error}")))?;
let http_status = payload.status();
let body = parse_aliyun_json_response(payload, "短信验证码发送失败").await?;
info!(
provider = "aliyun",
scene = request.scene.as_str(),
phone_masked = phone_masked.as_str(),
http_status = http_status.as_u16(),
provider_code = body.code.as_deref().unwrap_or("unknown"),
provider_message = body.message.as_deref().unwrap_or("unknown"),
provider_request_id = body
.request_id
.as_deref()
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.as_deref()))
.unwrap_or("unknown"),
provider_out_id = body
.model
.as_ref()
.and_then(|model| model.out_id.as_deref())
.unwrap_or("unknown"),
success = body.success.unwrap_or(false),
"阿里云短信发送接口返回响应"
);
if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") {
warn!(
provider = "aliyun",
scene = request.scene.as_str(),
phone_masked = phone_masked.as_str(),
http_status = http_status.as_u16(),
provider_code = body.code.as_deref().unwrap_or("unknown"),
provider_message = body.message.as_deref().unwrap_or("unknown"),
provider_request_id = body
.request_id
.as_deref()
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.as_deref()))
.unwrap_or("unknown"),
provider_out_id = body
.model
.as_ref()
.and_then(|model| model.out_id.as_deref())
.unwrap_or("unknown"),
"阿里云短信发送接口返回业务失败"
);
return Err(map_aliyun_provider_error(
"短信验证码发送失败",
body.message,
@@ -1173,6 +1244,23 @@ fn build_provider_error_message(prefix: &str, provider_message: &str) -> String
}
}
fn mask_phone_number(phone_number: &str) -> String {
let chars: Vec<char> = phone_number.chars().collect();
if chars.len() <= 4 {
return "*".repeat(chars.len().max(1));
}
let prefix_len = chars.len().min(3);
let suffix_len = 4.min(chars.len().saturating_sub(prefix_len));
let mask_len = chars.len().saturating_sub(prefix_len + suffix_len);
let mut masked = String::new();
masked.extend(chars.iter().take(prefix_len));
masked.push_str(&"*".repeat(mask_len.max(1)));
if suffix_len > 0 {
masked.extend(chars.iter().skip(chars.len() - suffix_len));
}
masked
}
impl fmt::Display for SmsProviderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -1469,4 +1557,65 @@ mod tests {
"Action=SendSmsVerifyCode&PhoneNumber=13800138000&TemplateParam=%7B%22code%22%3A%22%23%23code%23%23%22%7D"
);
}
#[test]
fn aliyun_send_response_deserializes_pascal_case_fields() {
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(
r#"{
"Code": "OK",
"Message": "成功",
"RequestId": "req_123",
"Success": true,
"Model": {
"BizId": "biz_456",
"OutId": "out_789",
"RequestId": "req_model_001"
}
}"#,
)
.expect("aliyun send response should deserialize");
assert_eq!(payload.code.as_deref(), Some("OK"));
assert_eq!(payload.message.as_deref(), Some("成功"));
assert_eq!(payload.request_id.as_deref(), Some("req_123"));
assert_eq!(payload.success, Some(true));
assert_eq!(
payload.model.as_ref().and_then(|model| model.out_id.as_deref()),
Some("out_789")
);
assert_eq!(
payload
.model
.as_ref()
.and_then(|model| model.request_id.as_deref()),
Some("req_model_001")
);
}
#[test]
fn aliyun_verify_response_deserializes_pascal_case_fields() {
let payload = serde_json::from_str::<AliyunCheckSmsVerifyCodeResponse>(
r#"{
"Code": "OK",
"Message": "成功",
"Success": true,
"Model": {
"OutId": "out_789",
"VerifyResult": "PASS"
}
}"#,
)
.expect("aliyun verify response should deserialize");
assert_eq!(payload.code.as_deref(), Some("OK"));
assert_eq!(payload.message.as_deref(), Some("成功"));
assert_eq!(payload.success, Some(true));
assert_eq!(
payload
.model
.as_ref()
.and_then(|model| model.verify_result.as_deref()),
Some("PASS")
);
}
}