Skip to main content

bpi_rs/login/
model.rs

1use serde::de;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use crate::ids::Mid;
5
6/// Login/navigation state returned by `/x/web-interface/nav`.
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct LoginNav {
9    /// Whether the current session is logged in.
10    #[serde(rename = "isLogin")]
11    pub is_login: bool,
12    /// Logged-in user ID. Guest responses return `0`, exposed as `None`.
13    #[serde(default, deserialize_with = "deserialize_optional_mid")]
14    pub mid: Option<Mid>,
15    /// Logged-in display name. Empty guest values are exposed as `None`.
16    #[serde(default, deserialize_with = "deserialize_optional_string")]
17    pub uname: Option<String>,
18    /// Logged-in avatar URL. Empty guest values are exposed as `None`.
19    #[serde(default, deserialize_with = "deserialize_optional_string")]
20    pub face: Option<String>,
21    /// WBI image keys. Bilibili returns these for guest sessions too.
22    pub wbi_img: LoginWbiImg,
23}
24
25/// WBI image key URLs embedded in the login nav response.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct LoginWbiImg {
28    /// URL containing the img key filename.
29    pub img_url: String,
30    /// URL containing the sub key filename.
31    pub sub_url: String,
32}
33
34/// Authenticated user's social counters returned by `/x/web-interface/nav/stat`.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct LoginStats {
37    /// Number of followed users.
38    pub following: u64,
39    /// Number of followers.
40    pub follower: u64,
41    /// Number of published dynamic posts.
42    pub dynamic_count: u64,
43}
44
45/// Current authenticated account coin balance.
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
47pub struct LoginCoinBalance {
48    /// Current coin balance.
49    pub money: f64,
50}
51
52/// Today's experience gained from coin operations.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct LoginTodayCoinExp {
56    /// Experience gained today.
57    pub value: u32,
58}
59
60/// Daily reward completion state returned by `/x/member/web/exp/reward`.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub struct LoginDailyReward {
63    /// Whether the daily login reward is complete.
64    pub login: bool,
65    /// Whether the daily watch reward is complete.
66    pub watch: bool,
67    /// Experience gained from daily coin operations.
68    pub coins: u32,
69    /// Whether the daily share reward is complete.
70    pub share: bool,
71    /// Whether the email-binding reward is complete.
72    pub email: bool,
73    /// Whether the phone-binding reward is complete.
74    pub tel: bool,
75    /// Whether the safe-question reward is complete.
76    pub safe_question: bool,
77    /// Whether the real-name verification reward is complete.
78    pub identify_card: bool,
79}
80
81/// Authenticated account profile returned by `/x/member/web/account`.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct LoginAccountInfo {
84    /// Current user's ID.
85    pub mid: Mid,
86    /// Current user's display name.
87    pub uname: String,
88    /// Login username, which may differ from the display name.
89    pub userid: String,
90    /// Current profile signature.
91    pub sign: String,
92    /// Birthday string returned by Bilibili, usually `YYYY-MM-DD`.
93    pub birthday: String,
94    /// Sex label returned by Bilibili.
95    pub sex: String,
96    /// Whether the account has not set a custom nickname.
97    pub nick_free: bool,
98    /// Membership rank string returned by Bilibili.
99    pub rank: String,
100}
101
102/// Authenticated account VIP state returned by `/x/vip/web/user/info`.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104pub struct LoginVipInfo {
105    /// Current user's ID.
106    pub mid: Mid,
107    /// VIP type returned by Bilibili.
108    pub vip_type: u8,
109    /// VIP status returned by Bilibili.
110    pub vip_status: u8,
111    /// VIP expiry timestamp in milliseconds.
112    pub vip_due_date: u64,
113    /// VIP payment type returned by Bilibili.
114    pub vip_pay_type: u8,
115    /// VIP theme type returned by Bilibili.
116    pub theme_type: u8,
117}
118
119impl LoginVipInfo {
120    /// Returns whether the account currently has an active VIP status.
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}