Merge remote-tracking branch 'origin/codex/send-sms'

This commit is contained in:
kdletters
2026-05-17 05:04:59 +08:00
9 changed files with 242 additions and 363 deletions

View File

@@ -16,21 +16,10 @@ JWT_EXPIRES_IN="7d"
SMS_AUTH_ENABLED="true" SMS_AUTH_ENABLED="true"
SMS_AUTH_PROVIDER="aliyun" SMS_AUTH_PROVIDER="aliyun"
ALIYUN_SMS_ACCESS_KEY_ID="LTAI5tM6VjoixveLUNQ7x6z9" ALIYUN_SMS_ENDPOINT="dysmsapi.aliyuncs.com"
ALIYUN_SMS_ACCESS_KEY_SECRET="w8Z8JlQKI1juGPSeirWwlvJfHp9frD" ALIYUN_SMS_SIGN_NAME="北京亓盒网络科技"
ALIYUN_SMS_ENDPOINT="dypnsapi.aliyuncs.com" ALIYUN_SMS_TEMPLATE_CODE="SMS_506245486"
ALIYUN_SMS_SIGN_NAME="速通互联验证码"
ALIYUN_SMS_TEMPLATE_CODE="100001"
ALIYUN_SMS_TEMPLATE_PARAM_KEY="code" 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" VITE_AUTH_ALLOW_DEV_GUEST="false"

View File

@@ -477,15 +477,15 @@
## 本地短信收不到验证码先查 provider ## 本地短信收不到验证码先查 provider
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。 - 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。 - 原因:本地 `.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`后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE` - 处理:真实短信联调时把 `.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` 才说明真实短信链路已生效。 - 验证:`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` - 关联:`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 语义 ## 手机验证码登录 500 先查短信 provider 语义
- 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN``biz.FREQUENCY``check frequency failed` - 现象:登录弹窗手机号验证码登录失败,浏览器看到 `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` - 处理:保留 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` - 验证:`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` - 关联:`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`

View File

@@ -85,9 +85,9 @@ SMS_AUTH_ENABLED=false
SMS_AUTH_PROVIDER=aliyun SMS_AUTH_PROVIDER=aliyun
ALIYUN_SMS_ACCESS_KEY_ID= ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET= ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_SIGN_NAME= ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技
ALIYUN_SMS_TEMPLATE_CODE= ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
ALIYUN_SMS_COUNTRY_CODE=86 ALIYUN_SMS_COUNTRY_CODE=86

View File

@@ -185,6 +185,30 @@ OpenTelemetry 现阶段可选 OTLP traces / metrics / logs但本地日志与
- `WECHAT_*` - `WECHAT_*`
- `ALIYUN_OSS_*` - `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`。任务配置、进度、领奖、钱包流水分别写入: 用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`。任务配置、进度、领奖、钱包流水分别写入:

1
server-rs/Cargo.lock generated
View File

@@ -1763,6 +1763,7 @@ dependencies = [
"platform-auth", "platform-auth",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"shared-kernel", "shared-kernel",
"time", "time",
"tokio", "tokio",

View File

@@ -170,11 +170,11 @@ impl Default for AppConfig {
dev_password_entry_auto_register_enabled: false, dev_password_entry_auto_register_enabled: false,
sms_auth_enabled: false, sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(), 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_id: None,
sms_access_key_secret: None, sms_access_key_secret: None,
sms_sign_name: "速通互联验证码".to_string(), sms_sign_name: "北京亓盒网络科技".to_string(),
sms_template_code: "100001".to_string(), sms_template_code: "SMS_506245486".to_string(),
sms_template_param_key: "code".to_string(), sms_template_param_key: "code".to_string(),
sms_country_code: "86".to_string(), sms_country_code: "86".to_string(),
sms_scheme_name: None, sms_scheme_name: None,
@@ -1069,7 +1069,10 @@ mod tests {
config.dashscope_base_url, config.dashscope_base_url,
"https://dashscope.aliyuncs.com/api/v1" "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!( assert_eq!(
config.wechat_authorize_endpoint, config.wechat_authorize_endpoint,
"https://open.weixin.qq.com/connect/qrconnect" "https://open.weixin.qq.com/connect/qrconnect"

View File

@@ -9,6 +9,7 @@ platform-auth = { workspace = true }
shared-kernel = { workspace = true } shared-kernel = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true, features = ["formatting", "parsing"] } time = { workspace = true, features = ["formatting", "parsing"] }
tracing = { workspace = true } tracing = { workspace = true }

View File

@@ -18,10 +18,11 @@ use std::{
}; };
use platform_auth::{ use platform_auth::{
SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, SmsSendCodeRequest, hash_password,
verify_password, verify_password,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use shared_kernel::{ use shared_kernel::{
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string, build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
normalize_optional_string, normalize_required_string, parse_rfc3339, normalize_optional_string, normalize_required_string, parse_rfc3339,
@@ -77,6 +78,7 @@ struct StoredRefreshSession {
struct StoredPhoneCode { struct StoredPhoneCode {
phone_number: String, phone_number: String,
scene: PhoneAuthScene, scene: PhoneAuthScene,
verify_code_hash: String,
expires_at: String, expires_at: String,
last_sent_at: String, last_sent_at: String,
failed_attempts: u32, failed_attempts: u32,
@@ -117,6 +119,7 @@ pub struct AuthUserService {
pub struct PhoneAuthService { pub struct PhoneAuthService {
store: InMemoryAuthStore, store: InMemoryAuthStore,
sms_provider: SmsAuthProvider, sms_provider: SmsAuthProvider,
verify_code_salt: String,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -431,6 +434,7 @@ impl PhoneAuthService {
Self { Self {
store, store,
sms_provider, sms_provider,
verify_code_salt: new_uuid_simple_string(),
} }
} }
@@ -442,6 +446,7 @@ impl PhoneAuthService {
let scene = input.scene.clone(); let scene = input.scene.clone();
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
let national_phone_number = build_national_phone_number(&normalized_phone.e164)?; let national_phone_number = build_national_phone_number(&normalized_phone.e164)?;
let verify_code = self.generate_phone_verify_code();
info!( info!(
scene = scene.as_str(), scene = scene.as_str(),
provider = self.sms_provider.kind().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| { let expires_at = format_rfc3339(expires_at).map_err(|message| {
PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{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 let provider_result = self
.sms_provider .sms_provider
.send_code(SmsSendCodeRequest { .send_code(SmsSendCodeRequest {
national_phone_number, national_phone_number,
scene: input.scene.as_str().to_string(), scene: input.scene.as_str().to_string(),
verify_code,
}) })
.await .await
.map_err(map_sms_provider_error_to_phone_error)?; .map_err(map_sms_provider_error_to_phone_error)?;
@@ -488,6 +500,7 @@ impl PhoneAuthService {
StoredPhoneCode { StoredPhoneCode {
phone_number: normalized_phone.e164.clone(), phone_number: normalized_phone.e164.clone(),
scene, scene,
verify_code_hash,
expires_at, expires_at,
last_sent_at: format_rfc3339(now).map_err(|message| { last_sent_at: format_rfc3339(now).map_err(|message| {
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
@@ -516,28 +529,12 @@ impl PhoneAuthService {
) -> Result<PhoneLoginResult, PhoneAuthError> { ) -> Result<PhoneLoginResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?; 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, &normalized_phone.e164,
&PhoneAuthScene::Login, &PhoneAuthScene::Login,
&input.verify_code,
now, 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 if let Some(user) = self
.store .store
@@ -582,30 +579,12 @@ impl PhoneAuthService {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?; verify_sms_code_format(&input.verify_code)?;
validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?; 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, &normalized_phone.e164,
&PhoneAuthScene::ResetPassword, &PhoneAuthScene::ResetPassword,
&input.verify_code,
now, 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 self.store
.find_by_phone_number(&normalized_phone.e164)? .find_by_phone_number(&normalized_phone.e164)?
@@ -632,28 +611,12 @@ impl PhoneAuthService {
) -> Result<BindWechatPhoneResult, PhoneAuthError> { ) -> Result<BindWechatPhoneResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?; verify_sms_code_format(&input.verify_code)?;
let provider_out_id = self.store.assert_phone_code_active( self.verify_phone_code(
&normalized_phone.e164, &normalized_phone.e164,
&PhoneAuthScene::BindPhone, &PhoneAuthScene::BindPhone,
&input.verify_code,
now, 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 let current_user = self
.store .store
@@ -677,6 +640,35 @@ impl PhoneAuthService {
}) })
} }
fn verify_phone_code(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
verify_code: &str,
now: OffsetDateTime,
) -> Result<Option<String>, 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( pub async fn bind_wechat_verified_phone(
&self, &self,
input: BindWechatVerifiedPhoneInput, input: BindWechatVerifiedPhoneInput,
@@ -1518,12 +1510,12 @@ impl InMemoryAuthStore {
}) })
} }
fn assert_phone_code_active( fn get_active_phone_code(
&self, &self,
phone_number: &str, phone_number: &str,
scene: &PhoneAuthScene, scene: &PhoneAuthScene,
now: OffsetDateTime, now: OffsetDateTime,
) -> Result<Option<String>, PhoneAuthError> { ) -> Result<StoredPhoneCode, PhoneAuthError> {
let mut state = self let mut state = self
.inner .inner
.lock() .lock()
@@ -1543,7 +1535,7 @@ impl InMemoryAuthStore {
state.phone_codes_by_key.remove(&key); state.phone_codes_by_key.remove(&key);
return Err(PhoneAuthError::VerifyCodeExpired); return Err(PhoneAuthError::VerifyCodeExpired);
} }
Ok(stored.provider_out_id) Ok(stored)
} }
fn consume_phone_code_success( fn consume_phone_code_success(
@@ -2065,7 +2057,6 @@ impl InMemoryAuthStore {
fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError { fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError {
match error { match error {
SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode,
SmsProviderError::InvalidConfig(message) => { SmsProviderError::InvalidConfig(message) => {
PhoneAuthError::SmsProviderInvalidConfig(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::<String>();
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<String, String> { fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
format_shared_rfc3339(value) format_shared_rfc3339(value)
} }
@@ -2655,6 +2676,14 @@ mod tests {
assert!(bind_result.await.is_ok()); 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] #[tokio::test]
async fn phone_login_expires_code_after_too_many_wrong_attempts() { async fn phone_login_expires_code_after_too_many_wrong_attempts() {
let service = build_phone_service(build_store()); let service = build_phone_service(build_store());

View File

@@ -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_NAME: &str = "genarrative_refresh_session";
pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth"; pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth";
pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30; 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_COUNTRY_CODE: &str = "86";
pub const DEFAULT_SMS_TEMPLATE_PARAM_KEY: &str = "code"; pub const DEFAULT_SMS_TEMPLATE_PARAM_KEY: &str = "code";
pub const DEFAULT_SMS_MOCK_VERIFY_CODE: &str = "123456"; pub const DEFAULT_SMS_MOCK_VERIFY_CODE: &str = "123456";
@@ -164,6 +164,7 @@ pub struct SmsAuthConfig {
pub struct SmsSendCodeRequest { pub struct SmsSendCodeRequest {
pub national_phone_number: String, pub national_phone_number: String,
pub scene: String, pub scene: String,
pub verify_code: String,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -174,13 +175,6 @@ pub struct SmsSendCodeResult {
pub provider_out_id: Option<String>, pub provider_out_id: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SmsVerifyCodeRequest {
pub national_phone_number: String,
pub verify_code: String,
pub provider_out_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthScene { pub enum WechatAuthScene {
Desktop, Desktop,
@@ -380,7 +374,7 @@ struct WechatPhoneNumberInfo {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct AliyunSendSmsVerifyCodeResponse { struct AliyunSendSmsResponse {
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。 // 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
#[serde(default, rename = "Code")] #[serde(default, rename = "Code")]
code: Option<String>, code: Option<String>,
@@ -388,41 +382,8 @@ struct AliyunSendSmsVerifyCodeResponse {
message: Option<String>, message: Option<String>,
#[serde(default, rename = "RequestId")] #[serde(default, rename = "RequestId")]
request_id: Option<String>, request_id: Option<String>,
#[serde(default, rename = "Success")]
success: Option<bool>,
#[serde(default, rename = "Model")]
model: Option<AliyunSendSmsVerifyCodeModel>,
}
#[derive(Debug, Deserialize)]
struct AliyunSendSmsVerifyCodeModel {
#[serde(default, rename = "BizId")] #[serde(default, rename = "BizId")]
_biz_id: Option<String>, biz_id: Option<String>,
#[serde(default, rename = "OutId")]
out_id: Option<String>,
#[serde(default, rename = "RequestId")]
request_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AliyunCheckSmsVerifyCodeResponse {
// 校验接口同样返回首字母大写字段名,保持和发送接口一致的显式映射。
#[serde(default, rename = "Code")]
code: Option<String>,
#[serde(default, rename = "Message")]
message: Option<String>,
#[serde(default, rename = "Success")]
success: Option<bool>,
#[serde(default, rename = "Model")]
model: Option<AliyunCheckSmsVerifyCodeModel>,
}
#[derive(Debug, Deserialize)]
struct AliyunCheckSmsVerifyCodeModel {
#[serde(default, rename = "OutId")]
_out_id: Option<String>,
#[serde(default, rename = "VerifyResult")]
verify_result: Option<String>,
} }
impl JwtConfig { 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 { match self {
Self::Mock(provider) => provider.verify_code(request).await, Self::Mock(provider) => Some(provider.mock_verify_code()),
Self::Aliyun(provider) => provider.verify_code(request).await, Self::Aliyun(_) => None,
} }
} }
} }
@@ -1228,6 +1189,7 @@ impl MockSmsAuthProvider {
&self, &self,
request: SmsSendCodeRequest, request: SmsSendCodeRequest,
) -> Result<SmsSendCodeResult, SmsProviderError> { ) -> Result<SmsSendCodeResult, SmsProviderError> {
let _verify_code = request.verify_code;
let provider_out_id = let provider_out_id =
build_sms_provider_out_id(&request.scene, &request.national_phone_number); 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> { fn mock_verify_code(&self) -> &str {
if request.verify_code.trim() != self.config.mock_verify_code { self.config.mock_verify_code.as_str()
return Err(SmsProviderError::InvalidVerifyCode);
}
Ok(())
} }
} }
@@ -1256,8 +1215,7 @@ impl AliyunSmsAuthProvider {
build_sms_provider_out_id(&request.scene, &request.national_phone_number); build_sms_provider_out_id(&request.scene, &request.national_phone_number);
let phone_masked = mask_phone_number(&request.national_phone_number); let phone_masked = mask_phone_number(&request.national_phone_number);
let template_param = serde_json::json!({ let template_param = serde_json::json!({
self.config.template_param_key.clone(): "##code##", self.config.template_param_key.clone(): request.verify_code.trim(),
"min": self.config.valid_time_seconds,
}) })
.to_string(); .to_string();
info!( info!(
@@ -1267,54 +1225,28 @@ impl AliyunSmsAuthProvider {
endpoint = self.config.endpoint.as_str(), endpoint = self.config.endpoint.as_str(),
sign_name = self.config.sign_name.as_str(), sign_name = self.config.sign_name.as_str(),
template_code = self.config.template_code.as_str(), template_code = self.config.template_code.as_str(),
code_length = self.config.code_length,
valid_time_seconds = self.config.valid_time_seconds, valid_time_seconds = self.config.valid_time_seconds,
interval_seconds = self.config.interval_seconds, interval_seconds = self.config.interval_seconds,
provider_out_id = provider_out_id.as_str(), provider_out_id = provider_out_id.as_str(),
"准备调用阿里云短信发送接口" "准备调用阿里云 SendSms 短信发送接口"
); );
let mut query = BTreeMap::new(); 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("Format".to_string(), "json".to_string());
query.insert("Version".to_string(), "2017-05-25".to_string()); query.insert("Version".to_string(), "2017-05-25".to_string());
query.insert( query.insert(
"PhoneNumber".to_string(), "PhoneNumbers".to_string(),
request.national_phone_number.trim().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("SignName".to_string(), self.config.sign_name.clone());
query.insert( query.insert(
"TemplateCode".to_string(), "TemplateCode".to_string(),
self.config.template_code.clone(), self.config.template_code.clone(),
); );
query.insert("TemplateParam".to_string(), template_param); 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()); query.insert("OutId".to_string(), provider_out_id.clone());
if let Some(scheme_name) = self.config.scheme_name.clone() { let signature_headers = self.build_signature_headers("SendSms", &query)?;
query.insert("SchemeName".to_string(), scheme_name);
}
let signature_headers = self.build_signature_headers("SendSmsVerifyCode", &query)?;
let payload = self let payload = self
.client .client
@@ -1334,23 +1266,12 @@ impl AliyunSmsAuthProvider {
http_status = http_status.as_u16(), http_status = http_status.as_u16(),
provider_code = body.code.as_deref().unwrap_or("unknown"), provider_code = body.code.as_deref().unwrap_or("unknown"),
provider_message = body.message.as_deref().unwrap_or("unknown"), provider_message = body.message.as_deref().unwrap_or("unknown"),
provider_request_id = body provider_request_id = body.request_id.as_deref().unwrap_or("unknown"),
.request_id provider_out_id = provider_out_id.as_str(),
.as_deref() provider_biz_id = body.biz_id.as_deref().unwrap_or("unknown"),
.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") { if body.code.as_deref() != Some("OK") {
warn!( warn!(
provider = "aliyun", provider = "aliyun",
scene = request.scene.as_str(), scene = request.scene.as_str(),
@@ -1358,19 +1279,9 @@ impl AliyunSmsAuthProvider {
http_status = http_status.as_u16(), http_status = http_status.as_u16(),
provider_code = body.code.as_deref().unwrap_or("unknown"), provider_code = body.code.as_deref().unwrap_or("unknown"),
provider_message = body.message.as_deref().unwrap_or("unknown"), provider_message = body.message.as_deref().unwrap_or("unknown"),
provider_request_id = body provider_request_id = body.request_id.as_deref().unwrap_or("unknown"),
.request_id provider_out_id = provider_out_id.as_str(),
.as_deref() provider_biz_id = body.biz_id.as_deref().unwrap_or("unknown"),
.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( return Err(map_aliyun_provider_error(
@@ -1383,65 +1294,11 @@ impl AliyunSmsAuthProvider {
Ok(SmsSendCodeResult { Ok(SmsSendCodeResult {
cooldown_seconds: self.config.interval_seconds, cooldown_seconds: self.config.interval_seconds,
expires_in_seconds: self.config.valid_time_seconds, expires_in_seconds: self.config.valid_time_seconds,
provider_request_id: body.request_id.or_else(|| { provider_request_id: body.request_id,
body.model provider_out_id: Some(provider_out_id),
.as_ref()
.and_then(|model| model.request_id.clone())
}),
provider_out_id: body.model.and_then(|model| model.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( fn build_signature_headers(
&self, &self,
action: &str, action: &str,
@@ -1972,16 +1829,15 @@ fn aliyun_percent_encode(value: &str) -> String {
async fn parse_aliyun_json_response( async fn parse_aliyun_json_response(
response: reqwest::Response, response: reqwest::Response,
fallback_message: &str, fallback_message: &str,
) -> Result<AliyunSendSmsVerifyCodeResponse, SmsProviderError> { ) -> Result<AliyunSendSmsResponse, SmsProviderError> {
let status = response.status(); let status = response.status();
let body = response let body = response
.text() .text()
.await .await
.map_err(|error| SmsProviderError::Upstream(format!("{fallback_message}{error}")))?; .map_err(|error| SmsProviderError::Upstream(format!("{fallback_message}{error}")))?;
let payload = let payload = serde_json::from_str::<AliyunSendSmsResponse>(&body).map_err(|error| {
serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(&body).map_err(|error| { SmsProviderError::Upstream(format!("{fallback_message}:响应解析失败:{error}"))
SmsProviderError::Upstream(format!("{fallback_message}:响应解析失败:{error}")) })?;
})?;
if status.is_client_error() || status.is_server_error() { if status.is_client_error() || status.is_server_error() {
return Err(map_http_status_to_sms_provider_error( return Err(map_http_status_to_sms_provider_error(
fallback_message, fallback_message,
@@ -1993,29 +1849,6 @@ async fn parse_aliyun_json_response(
Ok(payload) Ok(payload)
} }
async fn parse_aliyun_json_response_for_verify(
response: reqwest::Response,
) -> Result<AliyunCheckSmsVerifyCodeResponse, SmsProviderError> {
let status = response.status();
let body = response
.text()
.await
.map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?;
let payload =
serde_json::from_str::<AliyunCheckSmsVerifyCodeResponse>(&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::<Value>(&body).ok(),
));
}
Ok(payload)
}
fn map_http_status_to_sms_provider_error( fn map_http_status_to_sms_provider_error(
fallback_message: &str, fallback_message: &str,
status: StatusCode, status: StatusCode,
@@ -2053,13 +1886,6 @@ fn map_aliyun_provider_error(
let provider_code = provider_code.unwrap_or_default(); let provider_code = provider_code.unwrap_or_default();
let normalized_code = provider_code.trim().to_ascii_uppercase(); 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") if normalized_code.contains("MOBILE")
|| normalized_code.contains("PHONE") || normalized_code.contains("PHONE")
|| normalized_code.contains("SIGN") || normalized_code.contains("SIGN")
@@ -2350,6 +2176,48 @@ mod tests {
.expect("mock sms config should be valid") .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] #[test]
fn round_trip_sign_and_verify_access_token() { fn round_trip_sign_and_verify_access_token() {
let config = build_jwt_config(); let config = build_jwt_config();
@@ -2491,13 +2359,14 @@ mod tests {
} }
#[tokio::test] #[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 = let provider =
SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build"); SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build");
let send_result = provider let send_result = provider
.send_code(SmsSendCodeRequest { .send_code(SmsSendCodeRequest {
national_phone_number: "13800138000".to_string(), national_phone_number: "13800138000".to_string(),
scene: "login".to_string(), scene: "login".to_string(),
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
}) })
.await .await
.expect("send code should succeed"); .expect("send code should succeed");
@@ -2512,32 +2381,41 @@ mod tests {
Some("mock-request-id") Some("mock-request-id")
); );
assert!(send_result.provider_out_id.is_some()); assert!(send_result.provider_out_id.is_some());
assert_eq!(
provider provider.mock_verify_code(),
.verify_code(SmsVerifyCodeRequest { Some(DEFAULT_SMS_MOCK_VERIFY_CODE)
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");
} }
#[tokio::test] #[tokio::test]
async fn mock_sms_provider_rejects_wrong_code() { #[ignore = "requires real Aliyun SMS credentials and sends an actual SMS"]
let provider = async fn aliyun_send_sms_real_provider_sends_verify_code() {
SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build"); 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 let send_result = provider
.verify_code(SmsVerifyCodeRequest { .send_code(SmsSendCodeRequest {
national_phone_number: "13800138000".to_string(), national_phone_number: phone_number.clone(),
verify_code: "000000".to_string(), scene: "real_test".to_string(),
provider_out_id: None, verify_code: "123456".to_string(),
}) })
.await .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] #[test]
@@ -2574,14 +2452,14 @@ mod tests {
let mut params = BTreeMap::new(); let mut params = BTreeMap::new();
params.insert( params.insert(
"TemplateParam".to_string(), "TemplateParam".to_string(),
"{\"code\":\"##code##\"}".to_string(), "{\"code\":\"123456\"}".to_string(),
); );
params.insert("Action".to_string(), "SendSmsVerifyCode".to_string()); params.insert("Action".to_string(), "SendSms".to_string());
params.insert("PhoneNumber".to_string(), "13800138000".to_string()); params.insert("PhoneNumbers".to_string(), "13800138000".to_string());
assert_eq!( assert_eq!(
canonicalize_aliyun_form_params(&params), canonicalize_aliyun_form_params(&params),
"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 let headers = provider
.build_signature_headers( .build_signature_headers(
"SendSmsVerifyCode", "SendSms",
&BTreeMap::from([("Action".to_string(), "SendSmsVerifyCode".to_string())]), &BTreeMap::from([("Action".to_string(), "SendSms".to_string())]),
) )
.expect("signature headers should build"); .expect("signature headers should build");
@@ -2646,17 +2524,12 @@ mod tests {
#[test] #[test]
fn aliyun_send_response_deserializes_pascal_case_fields() { fn aliyun_send_response_deserializes_pascal_case_fields() {
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>( let payload = serde_json::from_str::<AliyunSendSmsResponse>(
r#"{ r#"{
"Code": "OK", "Code": "OK",
"Message": "成功", "Message": "成功",
"RequestId": "req_123", "RequestId": "req_123",
"Success": true, "BizId": "biz_456"
"Model": {
"BizId": "biz_456",
"OutId": "out_789",
"RequestId": "req_model_001"
}
}"#, }"#,
) )
.expect("aliyun send response should deserialize"); .expect("aliyun send response should deserialize");
@@ -2664,47 +2537,6 @@ mod tests {
assert_eq!(payload.code.as_deref(), Some("OK")); assert_eq!(payload.code.as_deref(), Some("OK"));
assert_eq!(payload.message.as_deref(), Some("成功")); assert_eq!(payload.message.as_deref(), Some("成功"));
assert_eq!(payload.request_id.as_deref(), Some("req_123")); assert_eq!(payload.request_id.as_deref(), Some("req_123"));
assert_eq!(payload.success, Some(true)); assert_eq!(payload.biz_id.as_deref(), Some("biz_456"));
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")
);
} }
} }