Skip to main content

bpi_rs/login/login_action/
sms.rs

1use crate::ApiEnvelope;
2use crate::BilibiliRequest;
3use crate::BpiError;
4use crate::BpiResult;
5use crate::login::LoginClient;
6use reqwest::header::SET_COOKIE;
7use serde::{Deserialize, Serialize};
8use tracing::{error, info};
9
10#[derive(Debug, Deserialize, Serialize)]
11pub struct SMSSendData {
12    captcha_key: String, // 短信登录 token
13}
14
15#[derive(Debug, Deserialize, Serialize)]
16struct SMSLoginData {
17    is_new: bool, // 是否为新用户
18    status: i32,  // 0:成功
19    url: String,  // 跳转url
20}
21
22/// Parameters for sending a web SMS login verification code.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct LoginSmsCodeParams {
25    cid: u32,
26    tel: String,
27    source: String,
28    token: String,
29    challenge: String,
30    validate: String,
31    seccode: String,
32}
33
34impl LoginSmsCodeParams {
35    /// Creates SMS-code request parameters.
36    pub fn new(
37        cid: u32,
38        tel: impl Into<String>,
39        token: impl Into<String>,
40        challenge: impl Into<String>,
41        validate: impl Into<String>,
42        seccode: impl Into<String>,
43    ) -> BpiResult<Self> {
44        let params = Self {
45            cid,
46            tel: tel.into(),
47            source: "main_web".to_string(),
48            token: token.into(),
49            challenge: challenge.into(),
50            validate: validate.into(),
51            seccode: seccode.into(),
52        };
53        params.validate()?;
54        Ok(params)
55    }
56
57    /// Sets the Bilibili login source marker. Defaults to `main_web`.
58    pub fn source(mut self, source: impl Into<String>) -> BpiResult<Self> {
59        self.source = source.into();
60        self.validate()?;
61        Ok(self)
62    }
63
64    fn validate(&self) -> BpiResult<()> {
65        if self.cid == 0 {
66            return Err(BpiError::invalid_parameter("cid", "cid must be non-zero"));
67        }
68        validate_required("tel", &self.tel)?;
69        validate_required("source", &self.source)?;
70        validate_required("token", &self.token)?;
71        validate_required("challenge", &self.challenge)?;
72        validate_required("validate", &self.validate)?;
73        validate_required("seccode", &self.seccode)?;
74        Ok(())
75    }
76
77    fn form_pairs(&self) -> Vec<(&'static str, String)> {
78        vec![
79            ("cid", self.cid.to_string()),
80            ("tel", self.tel.clone()),
81            ("source", self.source.clone()),
82            ("token", self.token.clone()),
83            ("challenge", self.challenge.clone()),
84            ("validate", self.validate.clone()),
85            ("seccode", self.seccode.clone()),
86        ]
87    }
88}
89
90fn validate_required(field: &'static str, value: &str) -> BpiResult<()> {
91    if value.trim().is_empty() {
92        return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
93    }
94
95    Ok(())
96}
97
98impl<'a> LoginClient<'a> {
99    /// 发送短信验证码(Web端)
100    pub async fn send_sms_code(&self, params: LoginSmsCodeParams) -> BpiResult<SMSSendData> {
101        self.client
102            .post("https://passport.bilibili.com/x/passport-login/web/sms/send")
103            .form(&params.form_pairs())
104            .send_bpi_payload("login.sms.send")
105            .await
106    }
107
108    /// 短信登录
109    ///
110    /// * `cid` - 国际冠字码
111    /// * `tel` - 手机号码
112    /// * `captcha_key` - 短信登录 token(基于send_sms_code)
113    /// * `code` - 短信验证码 5min过期
114    pub async fn login_with_sms(
115        &self,
116        cid: u32,
117        tel: u32,
118        captcha_key: &str,
119        code: &str,
120    ) -> Result<(), String> {
121        let form = vec![
122            ("cid", cid.to_string()),
123            ("tel", tel.to_string()),
124            ("code", code.to_string()),
125            ("source", "main_web".to_string()),
126            ("captcha_key", captcha_key.to_string()),
127            ("go_url", "https://www.bilibili.com".to_string()),
128            ("keep", true.to_string()),
129        ];
130
131        let response = self
132            .client
133            .post("https://passport.bilibili.com/x/passport-login/web/login/sms")
134            .form(&form)
135            .send()
136            .await
137            .map_err(|e| e.to_string())?;
138
139        if let Some(cookies) = response
140            .headers()
141            .get_all(SET_COOKIE)
142            .iter()
143            .map(|v| v.to_str().unwrap_or(""))
144            .collect::<Vec<_>>()
145            .join("; ")
146            .into()
147        {
148            info!("登录返回的 Cookie: {}", cookies);
149        }
150
151        let resp = response
152            .json::<ApiEnvelope<SMSLoginData>>()
153            .await
154            .map_err(|e| {
155                error!("解析短信登录响应失败: {:?}", e);
156                e.to_string()
157            })?;
158
159        if resp.code != 0 {
160            error!("短信登录失败: code={}, message={}", resp.code, resp.message);
161            return Err(resp.message);
162        }
163
164        match resp.code {
165            0 => {
166                info!("短信登录成功");
167                Ok(())
168            }
169            code => {
170                error!("验证码发送失败: code={}, message={}", code, resp.message);
171                let msg = match code {
172                    -400 => "请求错误".to_string(),
173                    1006 => "请输入正确的短信验证码".to_string(),
174                    1007 => "短信验证码已过期".to_string(),
175
176                    _ => resp.message,
177                };
178                Err(msg)
179            }
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn login_sms_code_params_rejects_blank_token() {
190        let err = LoginSmsCodeParams::new(
191            86,
192            "13800138000",
193            " ",
194            "challenge",
195            "validate",
196            "validate|jordan",
197        )
198        .unwrap_err();
199
200        assert!(matches!(
201            err,
202            BpiError::InvalidParameter { field: "token", .. }
203        ));
204    }
205}