From c3ad28577c3de9d82c02c10273f6c60a8a034594 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 16 May 2026 22:33:29 +0800 Subject: [PATCH 1/2] fix(auth): send sms verify codes via aliyun --- .hermes/shared-memory/pitfalls.md | 8 +- deploy/env/api-server.env.example | 6 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 24 ++ server-rs/Cargo.lock | 1 + server-rs/crates/api-server/src/config.rs | 11 +- server-rs/crates/module-auth/Cargo.toml | 1 + server-rs/crates/module-auth/src/lib.rs | 151 ++++--- server-rs/crates/platform-auth/src/lib.rs | 386 +++++------------- 8 files changed, 239 insertions(+), 349 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 13539b48..59eee809 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -453,15 +453,15 @@ ## 本地短信收不到验证码先查 provider - 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。 -- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。 -- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_PROVIDER` 显式设为 `aliyun`,然后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。 -- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]`,`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。 +- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。 +- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_PROVIDER` 显式设为 `aliyun`,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com`、`ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技`、`ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486`、`ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。`api-server` 重启会清掉未校验的本地验证码。 +- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]`,`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效;需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET` 和 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`。 - 关联:`scripts/dev-utils.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。 ## 手机验证码登录 500 先查短信 provider 语义 - 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN`、`biz.FREQUENCY` 或 `check frequency failed`。 -- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`,HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。 +- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`,HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。当前验证码校验已经改成本地哈希校验,登录阶段的验证码错误不会再调用阿里云校验接口;若登录前的发送阶段失败,应优先看 `SendSms` 返回的 `Code/Message`。 - 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run dev:api-server`。 - 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。 - 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。 diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 7420d6c9..782a5325 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -79,9 +79,9 @@ SMS_AUTH_ENABLED=false SMS_AUTH_PROVIDER=aliyun ALIYUN_SMS_ACCESS_KEY_ID= ALIYUN_SMS_ACCESS_KEY_SECRET= -ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com -ALIYUN_SMS_SIGN_NAME= -ALIYUN_SMS_TEMPLATE_CODE= +ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com +ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技 +ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486 ALIYUN_SMS_TEMPLATE_PARAM_KEY=code ALIYUN_SMS_COUNTRY_CODE=86 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 6cc9b533..43a8eddd 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -164,6 +164,30 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 - `WECHAT_*` - `ALIYUN_OSS_*` +### 手机验证码短信 + +手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。 + +生产默认短信配置: + +```env +ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com +ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技 +ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486 +ALIYUN_SMS_TEMPLATE_PARAM_KEY=code +``` + +阿里云模板参数固定发送为 `{"code":"<验证码>"}`。旧托管验证码相关变量如 `ALIYUN_SMS_CODE_LENGTH`、`ALIYUN_SMS_CODE_TYPE`、`ALIYUN_SMS_RETURN_VERIFY_CODE`、`ALIYUN_SMS_CASE_AUTH_POLICY`、`ALIYUN_SMS_SCHEME_NAME` 不再影响真实阿里云校验;验证码长度、有效期、冷却和失败次数由后端本地逻辑控制。真实短信联调仍需 `SMS_AUTH_PROVIDER=aliyun`、`SMS_AUTH_ENABLED=true` 和有效 `ALIYUN_SMS_ACCESS_KEY_*`。 + +如需在本地确认平台层确实调用阿里云 `SendSms`,可手动运行默认忽略的真实短信测试。该测试会向 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 发送验证码短信,普通 `cargo test` 不会执行: + +```powershell +$env:ALIYUN_SMS_ACCESS_KEY_ID="..." +$env:ALIYUN_SMS_ACCESS_KEY_SECRET="..." +$env:ALIYUN_SMS_REAL_TEST_PHONE_NUMBER="13800138000" +cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture +``` + ## 埋点与运营查询 用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`。任务配置、进度、领奖、钱包流水分别写入: diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 74415c0e..84fed9c7 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -1761,6 +1761,7 @@ dependencies = [ "platform-auth", "serde", "serde_json", + "sha2", "shared-kernel", "time", "tokio", diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index b8af62a4..abb6d2cb 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -164,11 +164,11 @@ impl Default for AppConfig { dev_password_entry_auto_register_enabled: false, sms_auth_enabled: false, sms_auth_provider: "mock".to_string(), - sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), + sms_endpoint: "dysmsapi.aliyuncs.com".to_string(), sms_access_key_id: None, sms_access_key_secret: None, - sms_sign_name: "速通互联验证码".to_string(), - sms_template_code: "100001".to_string(), + sms_sign_name: "北京亓盒网络科技".to_string(), + sms_template_code: "SMS_506245486".to_string(), sms_template_param_key: "code".to_string(), sms_country_code: "86".to_string(), sms_scheme_name: None, @@ -1035,7 +1035,10 @@ mod tests { config.dashscope_base_url, "https://dashscope.aliyuncs.com/api/v1" ); - assert_eq!(config.sms_endpoint, "dypnsapi.aliyuncs.com"); + assert_eq!(config.sms_endpoint, "dysmsapi.aliyuncs.com"); + assert_eq!(config.sms_sign_name, "北京亓盒网络科技"); + assert_eq!(config.sms_template_code, "SMS_506245486"); + assert_eq!(config.sms_template_param_key, "code"); assert_eq!( config.wechat_authorize_endpoint, "https://open.weixin.qq.com/connect/qrconnect" diff --git a/server-rs/crates/module-auth/Cargo.toml b/server-rs/crates/module-auth/Cargo.toml index eb7fa7b5..082ac278 100644 --- a/server-rs/crates/module-auth/Cargo.toml +++ b/server-rs/crates/module-auth/Cargo.toml @@ -9,6 +9,7 @@ platform-auth = { workspace = true } shared-kernel = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } time = { workspace = true, features = ["formatting", "parsing"] } tracing = { workspace = true } diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 815be0e7..9aabbda4 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -18,10 +18,11 @@ use std::{ }; use platform_auth::{ - SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password, + SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, SmsSendCodeRequest, hash_password, verify_password, }; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use shared_kernel::{ build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string, normalize_optional_string, normalize_required_string, parse_rfc3339, @@ -77,6 +78,7 @@ struct StoredRefreshSession { struct StoredPhoneCode { phone_number: String, scene: PhoneAuthScene, + verify_code_hash: String, expires_at: String, last_sent_at: String, failed_attempts: u32, @@ -117,6 +119,7 @@ pub struct AuthUserService { pub struct PhoneAuthService { store: InMemoryAuthStore, sms_provider: SmsAuthProvider, + verify_code_salt: String, } #[derive(Clone, Debug)] @@ -431,6 +434,7 @@ impl PhoneAuthService { Self { store, sms_provider, + verify_code_salt: new_uuid_simple_string(), } } @@ -442,6 +446,7 @@ impl PhoneAuthService { let scene = input.scene.clone(); let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; let national_phone_number = build_national_phone_number(&normalized_phone.e164)?; + let verify_code = self.generate_phone_verify_code(); info!( scene = scene.as_str(), provider = self.sms_provider.kind().as_str(), @@ -457,12 +462,19 @@ impl PhoneAuthService { let expires_at = format_rfc3339(expires_at).map_err(|message| { PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}")) })?; + let verify_code_hash = hash_phone_verify_code( + &self.verify_code_salt, + &normalized_phone.e164, + &scene, + &verify_code, + ); let provider_result = self .sms_provider .send_code(SmsSendCodeRequest { national_phone_number, scene: input.scene.as_str().to_string(), + verify_code, }) .await .map_err(map_sms_provider_error_to_phone_error)?; @@ -488,6 +500,7 @@ impl PhoneAuthService { StoredPhoneCode { phone_number: normalized_phone.e164.clone(), scene, + verify_code_hash, expires_at, last_sent_at: format_rfc3339(now).map_err(|message| { PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) @@ -516,28 +529,12 @@ impl PhoneAuthService { ) -> Result { let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; verify_sms_code_format(&input.verify_code)?; - let provider_out_id = self.store.assert_phone_code_active( + let provider_out_id = self.verify_phone_code( &normalized_phone.e164, &PhoneAuthScene::Login, + &input.verify_code, now, )?; - match self - .sms_provider - .verify_code(SmsVerifyCodeRequest { - national_phone_number: build_national_phone_number(&normalized_phone.e164)?, - verify_code: input.verify_code.trim().to_string(), - provider_out_id: provider_out_id.clone(), - }) - .await - { - Ok(()) => self - .store - .consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::Login)?, - Err(SmsProviderError::InvalidVerifyCode) => self - .store - .consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::Login)?, - Err(other) => return Err(map_sms_provider_error_to_phone_error(other)), - } if let Some(user) = self .store @@ -582,30 +579,12 @@ impl PhoneAuthService { let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; verify_sms_code_format(&input.verify_code)?; validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?; - let provider_out_id = self.store.assert_phone_code_active( + let provider_out_id = self.verify_phone_code( &normalized_phone.e164, &PhoneAuthScene::ResetPassword, + &input.verify_code, now, )?; - match self - .sms_provider - .verify_code(SmsVerifyCodeRequest { - national_phone_number: build_national_phone_number(&normalized_phone.e164)?, - verify_code: input.verify_code.trim().to_string(), - provider_out_id: provider_out_id.clone(), - }) - .await - { - Ok(()) => self.store.consume_phone_code_success( - &normalized_phone.e164, - &PhoneAuthScene::ResetPassword, - )?, - Err(SmsProviderError::InvalidVerifyCode) => self.store.consume_phone_code_failure( - &normalized_phone.e164, - &PhoneAuthScene::ResetPassword, - )?, - Err(other) => return Err(map_sms_provider_error_to_phone_error(other)), - } self.store .find_by_phone_number(&normalized_phone.e164)? @@ -632,28 +611,12 @@ impl PhoneAuthService { ) -> Result { let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; verify_sms_code_format(&input.verify_code)?; - let provider_out_id = self.store.assert_phone_code_active( + self.verify_phone_code( &normalized_phone.e164, &PhoneAuthScene::BindPhone, + &input.verify_code, now, )?; - match self - .sms_provider - .verify_code(SmsVerifyCodeRequest { - national_phone_number: build_national_phone_number(&normalized_phone.e164)?, - verify_code: input.verify_code.trim().to_string(), - provider_out_id, - }) - .await - { - Ok(()) => self - .store - .consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?, - Err(SmsProviderError::InvalidVerifyCode) => self - .store - .consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?, - Err(other) => return Err(map_sms_provider_error_to_phone_error(other)), - } let current_user = self .store @@ -677,6 +640,35 @@ impl PhoneAuthService { }) } + fn verify_phone_code( + &self, + phone_number: &str, + scene: &PhoneAuthScene, + verify_code: &str, + now: OffsetDateTime, + ) -> Result, PhoneAuthError> { + let stored = self.store.get_active_phone_code(phone_number, scene, now)?; + let expected_hash = + hash_phone_verify_code(&self.verify_code_salt, phone_number, scene, verify_code); + if stored.verify_code_hash != expected_hash { + self.store.consume_phone_code_failure(phone_number, scene)?; + return Err(PhoneAuthError::InvalidVerifyCode); + } + self.store.consume_phone_code_success(phone_number, scene)?; + Ok(stored.provider_out_id) + } + + fn generate_phone_verify_code(&self) -> String { + match self.sms_provider.kind() { + SmsAuthProviderKind::Mock => self + .sms_provider + .mock_verify_code() + .map(str::to_string) + .unwrap_or_else(|| "123456".to_string()), + SmsAuthProviderKind::Aliyun => generate_random_phone_verify_code(), + } + } + pub async fn bind_wechat_verified_phone( &self, input: BindWechatVerifiedPhoneInput, @@ -1518,12 +1510,12 @@ impl InMemoryAuthStore { }) } - fn assert_phone_code_active( + fn get_active_phone_code( &self, phone_number: &str, scene: &PhoneAuthScene, now: OffsetDateTime, - ) -> Result, PhoneAuthError> { + ) -> Result { let mut state = self .inner .lock() @@ -1543,7 +1535,7 @@ impl InMemoryAuthStore { state.phone_codes_by_key.remove(&key); return Err(PhoneAuthError::VerifyCodeExpired); } - Ok(stored.provider_out_id) + Ok(stored) } fn consume_phone_code_success( @@ -2065,7 +2057,6 @@ impl InMemoryAuthStore { fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError { match error { - SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode, SmsProviderError::InvalidConfig(message) => { PhoneAuthError::SmsProviderInvalidConfig(message) } @@ -2139,6 +2130,36 @@ fn build_random_password_seed() -> String { ) } +fn generate_random_phone_verify_code() -> String { + let digest = Sha256::digest(new_uuid_simple_string().as_bytes()); + let mut digits = digest + .iter() + .take(SMS_CODE_LENGTH) + .map(|byte| char::from(b'0' + (*byte % 10))) + .collect::(); + while digits.len() < SMS_CODE_LENGTH { + digits.push('0'); + } + digits +} + +fn hash_phone_verify_code( + salt: &str, + phone_number: &str, + scene: &PhoneAuthScene, + verify_code: &str, +) -> String { + let content = format!( + "{}:{}:{}:{}", + salt, + phone_number.trim(), + scene.as_str(), + verify_code.trim() + ); + let digest = Sha256::digest(content.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + fn format_rfc3339(value: OffsetDateTime) -> Result { format_shared_rfc3339(value) } @@ -2655,6 +2676,14 @@ mod tests { assert!(bind_result.await.is_ok()); } + #[test] + fn random_phone_verify_code_is_six_digits() { + let code = generate_random_phone_verify_code(); + + assert_eq!(code.len(), SMS_CODE_LENGTH); + assert!(code.chars().all(|character| character.is_ascii_digit())); + } + #[tokio::test] async fn phone_login_expires_code_after_too_many_wrong_attempts() { let service = build_phone_service(build_store()); diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index c0efd58a..da9221b6 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -24,7 +24,7 @@ pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60; pub const DEFAULT_REFRESH_COOKIE_NAME: &str = "genarrative_refresh_session"; pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth"; pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30; -pub const DEFAULT_SMS_ENDPOINT: &str = "dypnsapi.aliyuncs.com"; +pub const DEFAULT_SMS_ENDPOINT: &str = "dysmsapi.aliyuncs.com"; pub const DEFAULT_SMS_COUNTRY_CODE: &str = "86"; pub const DEFAULT_SMS_TEMPLATE_PARAM_KEY: &str = "code"; pub const DEFAULT_SMS_MOCK_VERIFY_CODE: &str = "123456"; @@ -164,6 +164,7 @@ pub struct SmsAuthConfig { pub struct SmsSendCodeRequest { pub national_phone_number: String, pub scene: String, + pub verify_code: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -174,13 +175,6 @@ pub struct SmsSendCodeResult { pub provider_out_id: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SmsVerifyCodeRequest { - pub national_phone_number: String, - pub verify_code: String, - pub provider_out_id: Option, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum WechatAuthScene { Desktop, @@ -380,7 +374,7 @@ struct WechatPhoneNumberInfo { } #[derive(Debug, Deserialize)] -struct AliyunSendSmsVerifyCodeResponse { +struct AliyunSendSmsResponse { // 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。 #[serde(default, rename = "Code")] code: Option, @@ -388,41 +382,8 @@ struct AliyunSendSmsVerifyCodeResponse { message: Option, #[serde(default, rename = "RequestId")] request_id: Option, - #[serde(default, rename = "Success")] - success: Option, - #[serde(default, rename = "Model")] - model: Option, -} - -#[derive(Debug, Deserialize)] -struct AliyunSendSmsVerifyCodeModel { #[serde(default, rename = "BizId")] - _biz_id: Option, - #[serde(default, rename = "OutId")] - out_id: Option, - #[serde(default, rename = "RequestId")] - request_id: Option, -} - -#[derive(Debug, Deserialize)] -struct AliyunCheckSmsVerifyCodeResponse { - // 校验接口同样返回首字母大写字段名,保持和发送接口一致的显式映射。 - #[serde(default, rename = "Code")] - code: Option, - #[serde(default, rename = "Message")] - message: Option, - #[serde(default, rename = "Success")] - success: Option, - #[serde(default, rename = "Model")] - model: Option, -} - -#[derive(Debug, Deserialize)] -struct AliyunCheckSmsVerifyCodeModel { - #[serde(default, rename = "OutId")] - _out_id: Option, - #[serde(default, rename = "VerifyResult")] - verify_result: Option, + biz_id: Option, } impl JwtConfig { @@ -681,10 +642,10 @@ impl SmsAuthProvider { } } - pub async fn verify_code(&self, request: SmsVerifyCodeRequest) -> Result<(), SmsProviderError> { + pub fn mock_verify_code(&self) -> Option<&str> { match self { - Self::Mock(provider) => provider.verify_code(request).await, - Self::Aliyun(provider) => provider.verify_code(request).await, + Self::Mock(provider) => Some(provider.mock_verify_code()), + Self::Aliyun(_) => None, } } } @@ -1228,6 +1189,7 @@ impl MockSmsAuthProvider { &self, request: SmsSendCodeRequest, ) -> Result { + let _verify_code = request.verify_code; let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number); @@ -1239,11 +1201,8 @@ impl MockSmsAuthProvider { }) } - async fn verify_code(&self, request: SmsVerifyCodeRequest) -> Result<(), SmsProviderError> { - if request.verify_code.trim() != self.config.mock_verify_code { - return Err(SmsProviderError::InvalidVerifyCode); - } - Ok(()) + fn mock_verify_code(&self) -> &str { + self.config.mock_verify_code.as_str() } } @@ -1256,8 +1215,7 @@ impl AliyunSmsAuthProvider { 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, + self.config.template_param_key.clone(): request.verify_code.trim(), }) .to_string(); info!( @@ -1267,54 +1225,28 @@ impl AliyunSmsAuthProvider { 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(), - "准备调用阿里云短信发送接口" + "准备调用阿里云 SendSms 短信发送接口" ); let mut query = BTreeMap::new(); - query.insert("Action".to_string(), "SendSmsVerifyCode".to_string()); + query.insert("Action".to_string(), "SendSms".to_string()); query.insert("Format".to_string(), "json".to_string()); query.insert("Version".to_string(), "2017-05-25".to_string()); query.insert( - "PhoneNumber".to_string(), + "PhoneNumbers".to_string(), request.national_phone_number.trim().to_string(), ); - query.insert("CountryCode".to_string(), self.config.country_code.clone()); query.insert("SignName".to_string(), self.config.sign_name.clone()); query.insert( "TemplateCode".to_string(), self.config.template_code.clone(), ); query.insert("TemplateParam".to_string(), template_param); - query.insert( - "CodeLength".to_string(), - self.config.code_length.to_string(), - ); - query.insert("CodeType".to_string(), self.config.code_type.to_string()); - query.insert( - "ValidTime".to_string(), - self.config.valid_time_seconds.to_string(), - ); - query.insert( - "Interval".to_string(), - self.config.interval_seconds.to_string(), - ); - query.insert( - "DuplicatePolicy".to_string(), - self.config.duplicate_policy.to_string(), - ); - query.insert( - "ReturnVerifyCode".to_string(), - self.config.return_verify_code.to_string(), - ); query.insert("OutId".to_string(), provider_out_id.clone()); - if let Some(scheme_name) = self.config.scheme_name.clone() { - query.insert("SchemeName".to_string(), scheme_name); - } - let signature_headers = self.build_signature_headers("SendSmsVerifyCode", &query)?; + let signature_headers = self.build_signature_headers("SendSms", &query)?; let payload = self .client @@ -1334,23 +1266,12 @@ impl AliyunSmsAuthProvider { 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), + provider_request_id = body.request_id.as_deref().unwrap_or("unknown"), + provider_out_id = provider_out_id.as_str(), + provider_biz_id = body.biz_id.as_deref().unwrap_or("unknown"), "阿里云短信发送接口返回响应" ); - if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") { + if body.code.as_deref() != Some("OK") { warn!( provider = "aliyun", scene = request.scene.as_str(), @@ -1358,19 +1279,9 @@ impl AliyunSmsAuthProvider { 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"), + provider_request_id = body.request_id.as_deref().unwrap_or("unknown"), + provider_out_id = provider_out_id.as_str(), + provider_biz_id = body.biz_id.as_deref().unwrap_or("unknown"), "阿里云短信发送接口返回业务失败" ); return Err(map_aliyun_provider_error( @@ -1383,65 +1294,11 @@ impl AliyunSmsAuthProvider { Ok(SmsSendCodeResult { cooldown_seconds: self.config.interval_seconds, expires_in_seconds: self.config.valid_time_seconds, - provider_request_id: body.request_id.or_else(|| { - body.model - .as_ref() - .and_then(|model| model.request_id.clone()) - }), - provider_out_id: body.model.and_then(|model| model.out_id), + provider_request_id: body.request_id, + provider_out_id: Some(provider_out_id), }) } - async fn verify_code(&self, request: SmsVerifyCodeRequest) -> Result<(), SmsProviderError> { - let mut query = BTreeMap::new(); - query.insert("Action".to_string(), "CheckSmsVerifyCode".to_string()); - query.insert("Format".to_string(), "json".to_string()); - query.insert("Version".to_string(), "2017-05-25".to_string()); - query.insert( - "PhoneNumber".to_string(), - request.national_phone_number.trim().to_string(), - ); - query.insert("CountryCode".to_string(), self.config.country_code.clone()); - query.insert( - "VerifyCode".to_string(), - request.verify_code.trim().to_string(), - ); - query.insert( - "CaseAuthPolicy".to_string(), - self.config.case_auth_policy.to_string(), - ); - if let Some(scheme_name) = self.config.scheme_name.clone() { - query.insert("SchemeName".to_string(), scheme_name); - } - if let Some(provider_out_id) = request.provider_out_id { - query.insert("OutId".to_string(), provider_out_id); - } - let signature_headers = self.build_signature_headers("CheckSmsVerifyCode", &query)?; - - let payload = self - .client - .post(build_aliyun_sms_url(&self.config.endpoint)?) - .headers(signature_headers) - .form(&query) - .send() - .await - .map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?; - - let body = parse_aliyun_json_response_for_verify(payload).await?; - if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") { - return Err(map_aliyun_provider_error( - "验证码校验失败", - body.message, - body.code, - )); - } - if body.model.and_then(|model| model.verify_result).as_deref() != Some("PASS") { - return Err(SmsProviderError::InvalidVerifyCode); - } - - Ok(()) - } - fn build_signature_headers( &self, action: &str, @@ -1972,16 +1829,15 @@ fn aliyun_percent_encode(value: &str) -> String { async fn parse_aliyun_json_response( response: reqwest::Response, fallback_message: &str, -) -> Result { +) -> Result { let status = response.status(); let body = response .text() .await .map_err(|error| SmsProviderError::Upstream(format!("{fallback_message}:{error}")))?; - let payload = - serde_json::from_str::(&body).map_err(|error| { - SmsProviderError::Upstream(format!("{fallback_message}:响应解析失败:{error}")) - })?; + let payload = serde_json::from_str::(&body).map_err(|error| { + SmsProviderError::Upstream(format!("{fallback_message}:响应解析失败:{error}")) + })?; if status.is_client_error() || status.is_server_error() { return Err(map_http_status_to_sms_provider_error( fallback_message, @@ -1993,29 +1849,6 @@ async fn parse_aliyun_json_response( Ok(payload) } -async fn parse_aliyun_json_response_for_verify( - response: reqwest::Response, -) -> Result { - let status = response.status(); - let body = response - .text() - .await - .map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?; - let payload = - serde_json::from_str::(&body).map_err(|error| { - SmsProviderError::Upstream(format!("验证码校验失败:响应解析失败:{error}")) - })?; - if status.is_client_error() || status.is_server_error() { - return Err(map_http_status_to_sms_provider_error( - "验证码校验失败", - status, - serde_json::from_str::(&body).ok(), - )); - } - - Ok(payload) -} - fn map_http_status_to_sms_provider_error( fallback_message: &str, status: StatusCode, @@ -2053,13 +1886,6 @@ fn map_aliyun_provider_error( let provider_code = provider_code.unwrap_or_default(); let normalized_code = provider_code.trim().to_ascii_uppercase(); - if normalized_code.contains("VERIFY") - || normalized_code.contains("CODE") - || normalized_code.contains("CHECK") - { - return SmsProviderError::InvalidVerifyCode; - } - if normalized_code.contains("MOBILE") || normalized_code.contains("PHONE") || normalized_code.contains("SIGN") @@ -2350,6 +2176,48 @@ mod tests { .expect("mock sms config should be valid") } + fn required_env_for_real_sms_test(name: &str) -> String { + std::env::var(name) + .ok() + .and_then(|value| normalize_required_string(&value)) + .unwrap_or_else(|| panic!("{name} must be set to run the real Aliyun SMS test")) + } + + fn optional_env_for_real_sms_test(name: &str, default_value: &str) -> String { + std::env::var(name) + .ok() + .and_then(|value| normalize_required_string(&value)) + .unwrap_or_else(|| default_value.to_string()) + } + + fn build_real_aliyun_sms_config_from_env() -> SmsAuthConfig { + SmsAuthConfig::new( + SmsAuthProviderKind::Aliyun, + optional_env_for_real_sms_test("ALIYUN_SMS_ENDPOINT", DEFAULT_SMS_ENDPOINT), + Some(required_env_for_real_sms_test("ALIYUN_SMS_ACCESS_KEY_ID")), + Some(required_env_for_real_sms_test( + "ALIYUN_SMS_ACCESS_KEY_SECRET", + )), + optional_env_for_real_sms_test("ALIYUN_SMS_SIGN_NAME", "北京亓盒网络科技"), + optional_env_for_real_sms_test("ALIYUN_SMS_TEMPLATE_CODE", "SMS_506245486"), + optional_env_for_real_sms_test( + "ALIYUN_SMS_TEMPLATE_PARAM_KEY", + DEFAULT_SMS_TEMPLATE_PARAM_KEY, + ), + optional_env_for_real_sms_test("ALIYUN_SMS_COUNTRY_CODE", DEFAULT_SMS_COUNTRY_CODE), + None, + DEFAULT_SMS_CODE_LENGTH, + DEFAULT_SMS_CODE_TYPE, + DEFAULT_SMS_VALID_TIME_SECONDS, + DEFAULT_SMS_INTERVAL_SECONDS, + DEFAULT_SMS_DUPLICATE_POLICY, + DEFAULT_SMS_CASE_AUTH_POLICY, + false, + DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), + ) + .expect("real aliyun sms config should be valid") + } + #[test] fn round_trip_sign_and_verify_access_token() { let config = build_jwt_config(); @@ -2491,13 +2359,14 @@ mod tests { } #[tokio::test] - async fn mock_sms_provider_sends_and_verifies_code() { + async fn mock_sms_provider_sends_code_and_exposes_fixed_verify_code() { let provider = SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build"); let send_result = provider .send_code(SmsSendCodeRequest { national_phone_number: "13800138000".to_string(), scene: "login".to_string(), + verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), }) .await .expect("send code should succeed"); @@ -2512,32 +2381,41 @@ mod tests { Some("mock-request-id") ); assert!(send_result.provider_out_id.is_some()); - - provider - .verify_code(SmsVerifyCodeRequest { - national_phone_number: "13800138000".to_string(), - verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), - provider_out_id: send_result.provider_out_id, - }) - .await - .expect("verify code should succeed"); + assert_eq!( + provider.mock_verify_code(), + Some(DEFAULT_SMS_MOCK_VERIFY_CODE) + ); } #[tokio::test] - async fn mock_sms_provider_rejects_wrong_code() { - let provider = - SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build"); + #[ignore = "requires real Aliyun SMS credentials and sends an actual SMS"] + async fn aliyun_send_sms_real_provider_sends_verify_code() { + let phone_number = required_env_for_real_sms_test("ALIYUN_SMS_REAL_TEST_PHONE_NUMBER"); + let provider = SmsAuthProvider::new(build_real_aliyun_sms_config_from_env()) + .expect("real aliyun provider should build"); - let error = provider - .verify_code(SmsVerifyCodeRequest { - national_phone_number: "13800138000".to_string(), - verify_code: "000000".to_string(), - provider_out_id: None, + let send_result = provider + .send_code(SmsSendCodeRequest { + national_phone_number: phone_number.clone(), + scene: "real_test".to_string(), + verify_code: "123456".to_string(), }) .await - .expect_err("wrong verify code should fail"); + .expect("real aliyun SendSms call should succeed"); - assert_eq!(error, SmsProviderError::InvalidVerifyCode); + println!( + "real Aliyun SendSms accepted phone={} request_id={:?} out_id={:?}", + mask_phone_number(&phone_number), + send_result.provider_request_id, + send_result.provider_out_id + ); + assert!(send_result.provider_request_id.is_some()); + assert!(send_result.provider_out_id.is_some()); + assert_eq!(send_result.cooldown_seconds, DEFAULT_SMS_INTERVAL_SECONDS); + assert_eq!( + send_result.expires_in_seconds, + DEFAULT_SMS_VALID_TIME_SECONDS + ); } #[test] @@ -2574,14 +2452,14 @@ mod tests { let mut params = BTreeMap::new(); params.insert( "TemplateParam".to_string(), - "{\"code\":\"##code##\"}".to_string(), + "{\"code\":\"123456\"}".to_string(), ); - params.insert("Action".to_string(), "SendSmsVerifyCode".to_string()); - params.insert("PhoneNumber".to_string(), "13800138000".to_string()); + params.insert("Action".to_string(), "SendSms".to_string()); + params.insert("PhoneNumbers".to_string(), "13800138000".to_string()); assert_eq!( canonicalize_aliyun_form_params(¶ms), - "Action=SendSmsVerifyCode&PhoneNumber=13800138000&TemplateParam=%7B%22code%22%3A%22%23%23code%23%23%22%7D" + "Action=SendSms&PhoneNumbers=13800138000&TemplateParam=%7B%22code%22%3A%22123456%22%7D" ); } @@ -2613,8 +2491,8 @@ mod tests { }; let headers = provider .build_signature_headers( - "SendSmsVerifyCode", - &BTreeMap::from([("Action".to_string(), "SendSmsVerifyCode".to_string())]), + "SendSms", + &BTreeMap::from([("Action".to_string(), "SendSms".to_string())]), ) .expect("signature headers should build"); @@ -2646,17 +2524,12 @@ mod tests { #[test] fn aliyun_send_response_deserializes_pascal_case_fields() { - let payload = serde_json::from_str::( + let payload = serde_json::from_str::( r#"{ "Code": "OK", "Message": "成功", "RequestId": "req_123", - "Success": true, - "Model": { - "BizId": "biz_456", - "OutId": "out_789", - "RequestId": "req_model_001" - } + "BizId": "biz_456" }"#, ) .expect("aliyun send response should deserialize"); @@ -2664,47 +2537,6 @@ mod tests { 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::( - 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") - ); + assert_eq!(payload.biz_id.as_deref(), Some("biz_456")); } } From aa78ea9adc4e28ea9135de42e7908264e6885000 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 16 May 2026 22:34:56 +0800 Subject: [PATCH 2/2] chore(env): update local sms config --- .env.local | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/.env.local b/.env.local index 34b87a66..b4098cb1 100644 --- a/.env.local +++ b/.env.local @@ -16,21 +16,10 @@ JWT_EXPIRES_IN="7d" SMS_AUTH_ENABLED="true" SMS_AUTH_PROVIDER="aliyun" -ALIYUN_SMS_ACCESS_KEY_ID="LTAI5tM6VjoixveLUNQ7x6z9" -ALIYUN_SMS_ACCESS_KEY_SECRET="w8Z8JlQKI1juGPSeirWwlvJfHp9frD" -ALIYUN_SMS_ENDPOINT="dypnsapi.aliyuncs.com" -ALIYUN_SMS_SIGN_NAME="速通互联验证码" -ALIYUN_SMS_TEMPLATE_CODE="100001" +ALIYUN_SMS_ENDPOINT="dysmsapi.aliyuncs.com" +ALIYUN_SMS_SIGN_NAME="北京亓盒网络科技" +ALIYUN_SMS_TEMPLATE_CODE="SMS_506245486" ALIYUN_SMS_TEMPLATE_PARAM_KEY="code" -ALIYUN_SMS_COUNTRY_CODE="86" -ALIYUN_SMS_SCHEME_NAME="" -ALIYUN_SMS_CODE_LENGTH="6" -ALIYUN_SMS_CODE_TYPE="1" -ALIYUN_SMS_VALID_TIME_SECONDS="300" -ALIYUN_SMS_INTERVAL_SECONDS="60" -ALIYUN_SMS_DUPLICATE_POLICY="1" -ALIYUN_SMS_CASE_AUTH_POLICY="1" -ALIYUN_SMS_RETURN_VERIFY_CODE="false" VITE_AUTH_ALLOW_DEV_GUEST="false"