Skip to main content

bpi_rs/login/
model.rs

1use serde::de;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use crate::ids::Mid;
5
6/// `/x/web-interface/nav` 返回的登录和导航状态。
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct LoginNav {
9    /// 当前会话是否已登录。
10    #[serde(rename = "isLogin")]
11    pub is_login: bool,
12    /// 已登录用户 ID。游客响应返回 `0`,对外暴露为 `None`。
13    #[serde(default, deserialize_with = "deserialize_optional_mid")]
14    pub mid: Option<Mid>,
15    /// 已登录显示名称。游客空值对外暴露为 `None`。
16    #[serde(default, deserialize_with = "deserialize_optional_string")]
17    pub uname: Option<String>,
18    /// 已登录头像 URL。游客空值对外暴露为 `None`。
19    #[serde(default, deserialize_with = "deserialize_optional_string")]
20    pub face: Option<String>,
21    /// WBI 图片 key。Bilibili 对游客会话也会返回这些字段。
22    pub wbi_img: LoginWbiImg,
23}
24
25/// 登录 nav 响应中嵌入的 WBI 图片 key URL。
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct LoginWbiImg {
28    /// 包含 img key 文件名的 URL。
29    pub img_url: String,
30    /// 包含 sub key 文件名的 URL。
31    pub sub_url: String,
32}
33
34/// `/x/web-interface/nav/stat` 返回的已认证用户社交计数。
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct LoginStats {
37    /// 关注用户数。
38    pub following: u64,
39    /// 粉丝数。
40    pub follower: u64,
41    /// 已发布动态数。
42    pub dynamic_count: u64,
43}
44
45/// 当前已认证账号的硬币余额。
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
47pub struct LoginCoinBalance {
48    /// 当前硬币余额。
49    pub money: f64,
50}
51
52/// 今日通过投币操作获得的经验。
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct LoginTodayCoinExp {
56    /// 今日获得的经验。
57    pub value: u32,
58}
59
60/// `/x/member/web/exp/reward` 返回的每日奖励完成状态。
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub struct LoginDailyReward {
63    /// 每日登录奖励是否完成。
64    pub login: bool,
65    /// 每日观看奖励是否完成。
66    pub watch: bool,
67    /// 每日投币操作获得的经验。
68    pub coins: u32,
69    /// 每日分享奖励是否完成。
70    pub share: bool,
71    /// 邮箱绑定奖励是否完成。
72    pub email: bool,
73    /// 手机绑定奖励是否完成。
74    pub tel: bool,
75    /// 安全问题奖励是否完成。
76    pub safe_question: bool,
77    /// 实名认证奖励是否完成。
78    pub identify_card: bool,
79}
80
81/// `/x/member/web/account` 返回的已认证账号资料。
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct LoginAccountInfo {
84    /// 当前用户 ID。
85    pub mid: Mid,
86    /// 当前用户显示名称。
87    pub uname: String,
88    /// 登录用户名,可能不同于显示名称。
89    pub userid: String,
90    /// 当前个人签名。
91    pub sign: String,
92    /// Bilibili 返回的生日字符串,通常为 `YYYY-MM-DD`。
93    pub birthday: String,
94    /// Bilibili 返回的性别标签。
95    pub sex: String,
96    /// 账号是否尚未设置自定义昵称。
97    pub nick_free: bool,
98    /// Bilibili 返回的会员 rank 字符串。
99    pub rank: String,
100}
101
102/// `/x/vip/web/user/info` 返回的已认证账号 VIP 状态。
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104pub struct LoginVipInfo {
105    /// 当前用户 ID。
106    pub mid: Mid,
107    /// Bilibili 返回的 VIP 类型。
108    pub vip_type: u8,
109    /// Bilibili 返回的 VIP 状态。
110    pub vip_status: u8,
111    /// VIP 到期时间戳,单位毫秒。
112    pub vip_due_date: u64,
113    /// Bilibili 返回的 VIP 支付类型。
114    pub vip_pay_type: u8,
115    /// Bilibili 返回的 VIP 主题类型。
116    pub theme_type: u8,
117}
118
119impl LoginVipInfo {
120    /// 返回账号当前是否拥有有效 VIP 状态。
121    pub fn is_active(self) -> bool {
122        self.vip_status == 1 && self.vip_due_date > 0
123    }
124}
125
126fn deserialize_optional_mid<'de, D>(deserializer: D) -> Result<Option<Mid>, D::Error>
127where
128    D: Deserializer<'de>,
129{
130    match Option::<u64>::deserialize(deserializer)? {
131        Some(0) | None => Ok(None),
132        Some(value) => Mid::new(value).map(Some).map_err(de::Error::custom),
133    }
134}
135
136fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
137where
138    D: Deserializer<'de>,
139{
140    Ok(Option::<String>::deserialize(deserializer)?
141        .and_then(|value| (!value.trim().is_empty()).then_some(value)))
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use serde::de::DeserializeOwned;
148
149    use crate::probe::endpoint_contract::EndpointContract;
150    use crate::{ApiEnvelope, BpiError};
151
152    const READ_INFO_ENDPOINTS: &[&str] = &["account-info", "coin", "nav", "stat", "today-coin-exp"];
153
154    fn local_vip_info_probe_body(name: &str) -> Option<serde_json::Value> {
155        let path = format!("target/bpi-probe-runs/login/vip-info/vip-info/{name}.response.json");
156        let bytes = std::fs::read(path).ok()?;
157        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
158        value
159            .get("response")
160            .and_then(|response| response.get("body"))
161            .cloned()
162    }
163
164    fn local_read_info_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
165        let path =
166            format!("target/bpi-probe-runs/login/read-info/{endpoint}/{profile}.response.json");
167        let bytes = std::fs::read(path).ok()?;
168        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
169        value
170            .get("response")
171            .and_then(|response| response.get("body"))
172            .cloned()
173    }
174
175    fn read_info_payload<T>(endpoint: &str, profile: &str) -> Result<Option<T>, BpiError>
176    where
177        T: DeserializeOwned,
178    {
179        let Some(body) = local_read_info_probe_body(endpoint, profile) else {
180            return Ok(None);
181        };
182
183        serde_json::from_value::<ApiEnvelope<T>>(body)?
184            .into_payload()
185            .map(Some)
186    }
187
188    fn fixture_bytes(
189        endpoint: &str,
190        case: &crate::probe::endpoint_contract::EndpointCase,
191    ) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
192        let fixture = case
193            .response
194            .fixture
195            .as_deref()
196            .ok_or_else(|| BpiError::unsupported_response("contract case missing fixture"))?;
197        let path = format!("tests/contracts/login/{endpoint}/{fixture}");
198
199        Ok(std::fs::read(path)?)
200    }
201
202    fn assert_fixture_matches_model(
203        model: &str,
204        bytes: &[u8],
205    ) -> Result<(), Box<dyn std::error::Error>> {
206        match model {
207            "LoginAccountInfo" => {
208                let payload = ApiEnvelope::<LoginAccountInfo>::from_slice(bytes)?.into_payload()?;
209                assert!(payload.mid.get() > 0);
210            }
211            "LoginCoinBalance" => {
212                let payload = ApiEnvelope::<LoginCoinBalance>::from_slice(bytes)?.into_payload()?;
213                assert!(payload.money >= 0.0);
214            }
215            "LoginNav" => {
216                let payload = ApiEnvelope::<LoginNav>::from_slice(bytes)?.into_payload()?;
217                assert!(payload.is_login);
218            }
219            "LoginStats" => {
220                let payload = ApiEnvelope::<LoginStats>::from_slice(bytes)?.into_payload()?;
221                assert!(payload.following > 0);
222            }
223            "LoginTodayCoinExp" => {
224                let payload =
225                    ApiEnvelope::<LoginTodayCoinExp>::from_slice(bytes)?.into_payload()?;
226                assert!(payload.value <= 50);
227            }
228            "LoginDailyReward" => {
229                let payload = ApiEnvelope::<LoginDailyReward>::from_slice(bytes)?.into_payload()?;
230                assert!(payload.coins <= 50);
231            }
232            "LoginVipInfo" => {
233                let payload = ApiEnvelope::<LoginVipInfo>::from_slice(bytes)?.into_payload()?;
234                assert!(payload.mid.get() > 0);
235            }
236            _ => {
237                return Err(Box::new(BpiError::unsupported_response(format!(
238                    "unknown login response model {model}"
239                ))));
240            }
241        }
242
243        Ok(())
244    }
245
246    fn assert_login_contract_fixtures_parse(
247        endpoint: &str,
248    ) -> Result<(), Box<dyn std::error::Error>> {
249        let contract = EndpointContract::from_slice(&std::fs::read(format!(
250            "tests/contracts/login/{endpoint}/contract.json"
251        ))?)?;
252
253        for case in &contract.cases {
254            if case.response.fixture.is_none() {
255                assert!(
256                    case.response.error.is_some() || case.response.http_status.is_some(),
257                    "contract case without fixture must document observed error/status"
258                );
259                continue;
260            }
261
262            let bytes = fixture_bytes(endpoint, case)?;
263            if let Some(model) = &case.response.rust_model {
264                assert_fixture_matches_model(model, &bytes)?;
265            } else if case.response.error.as_deref() == Some("requires_login") {
266                let err = ApiEnvelope::<serde_json::Value>::from_slice(&bytes)?
267                    .ensure_success()
268                    .unwrap_err();
269                assert!(err.requires_login());
270            }
271        }
272
273        Ok(())
274    }
275
276    #[test]
277    fn login_vip_info_matches_local_probe_outputs_when_available() -> Result<(), BpiError> {
278        let Some(normal_body) = local_vip_info_probe_body("normal") else {
279            return Ok(());
280        };
281        let Some(active_body) = local_vip_info_probe_body("vip") else {
282            return Ok(());
283        };
284
285        let normal: LoginVipInfo =
286            serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(normal_body)?.into_payload()?;
287        let active: LoginVipInfo =
288            serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(active_body)?.into_payload()?;
289
290        assert!(!normal.is_active());
291        assert!(active.is_active());
292        Ok(())
293    }
294
295    #[test]
296    fn login_vip_info_anonymous_probe_returns_login_required_when_available() -> Result<(), BpiError>
297    {
298        let Some(body) = local_vip_info_probe_body("anonymous") else {
299            return Ok(());
300        };
301
302        let err = serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(body)?
303            .ensure_success()
304            .unwrap_err();
305
306        assert!(err.requires_login());
307        Ok(())
308    }
309
310    #[test]
311    fn login_read_info_models_match_local_probe_outputs_when_available() -> Result<(), BpiError> {
312        for profile in ["normal", "vip"] {
313            if let Some(nav) = read_info_payload::<LoginNav>("nav", profile)? {
314                assert!(nav.is_login);
315                assert!(nav.mid.is_some());
316            }
317
318            let _ = read_info_payload::<LoginStats>("stat", profile)?;
319
320            if let Some(coin) = read_info_payload::<LoginCoinBalance>("coin", profile)? {
321                assert!(coin.money >= 0.0);
322            }
323
324            if let Some(exp) = read_info_payload::<LoginTodayCoinExp>("today-coin-exp", profile)? {
325                assert!(exp.value <= 50);
326            }
327
328            if let Some(account) = read_info_payload::<LoginAccountInfo>("account-info", profile)? {
329                assert!(account.mid.get() > 0);
330                assert!(!account.uname.trim().is_empty());
331            }
332        }
333        Ok(())
334    }
335
336    #[test]
337    fn login_read_info_anonymous_probes_return_login_required_when_available()
338    -> Result<(), BpiError> {
339        for endpoint in READ_INFO_ENDPOINTS {
340            let Some(body) = local_read_info_probe_body(endpoint, "anonymous") else {
341                continue;
342            };
343
344            let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
345                .ensure_success()
346                .unwrap_err();
347
348            assert!(err.requires_login());
349        }
350        Ok(())
351    }
352
353    #[test]
354    fn login_contract_response_fixtures_parse_declared_models()
355    -> Result<(), Box<dyn std::error::Error>> {
356        assert_login_contract_fixtures_parse("vip-info")?;
357        assert_login_contract_fixtures_parse("daily-reward")?;
358        for endpoint in READ_INFO_ENDPOINTS {
359            assert_login_contract_fixtures_parse(endpoint)?;
360        }
361        Ok(())
362    }
363}