Skip to main content

bpi_rs/dynamic/
get_dynamic_detail.rs

1use serde::{Deserialize, Deserializer, Serialize, de};
2
3use crate::models::{LevelInfo, Official, Pendant, Vip};
4
5#[derive(Debug, Serialize, Clone, Deserialize)]
6pub struct DynamicCardData {
7    pub card: DynamicCard,
8}
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct DynamicCard {
12    pub desc: Desc,
13    pub card: String,
14    pub extend_json: String,
15    pub display: serde_json::Value,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Desc {
20    pub uid: i64,
21    #[serde(rename = "type")]
22    pub type_field: i64,
23    pub rid: i64,
24    pub acl: i64,
25    pub view: i64,
26    pub repost: i64,
27    pub comment: i64,
28    pub like: i64,
29    pub is_liked: i64,
30    pub dynamic_id: i64,
31    pub timestamp: i64,
32    pub pre_dy_id: i64,
33    pub orig_dy_id: i64,
34    pub orig_type: i64,
35    pub user_profile: UserProfile,
36    pub spec_type: i64,
37    pub uid_type: i64,
38    pub stype: i64,
39    pub r_type: i64,
40    pub inner_id: i64,
41    pub status: i64,
42    pub dynamic_id_str: String,
43    pub pre_dy_id_str: String,
44    pub orig_dy_id_str: String,
45    pub rid_str: String,
46    pub bvid: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct UserProfile {
51    pub info: Info,
52    pub card: Card,
53    pub vip: Vip,
54    pub pendant: Pendant,
55    pub rank: String,
56    pub sign: String,
57    pub level_info: LevelInfo,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct Info {
62    pub uid: i64,
63    pub uname: String,
64    pub face: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Card {
69    pub official_verify: OfficialVerify,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct OfficialVerify {
74    #[serde(rename = "type")]
75    pub type_field: i64,
76}
77
78#[derive(Debug, Serialize, Clone, Deserialize)]
79pub struct RecentUpData {
80    /// 直播用户(暂不明确,可能为 null)
81    pub live_users: Option<serde_json::Value>,
82    /// 我的信息
83    pub my_info: Option<MyInfo>,
84    /// 最近更新的 UP 主列表
85    pub up_list: Vec<UpUser>,
86}
87
88/// 我的信息对象
89#[derive(Debug, Serialize, Clone, Deserialize)]
90pub struct MyInfo {
91    /// 个人动态数
92    #[serde(deserialize_with = "deserialize_i32_from_string_or_number")]
93    pub dyns: i32,
94    /// 头像地址
95    pub face: String,
96    /// 粉丝数
97    pub follower: String,
98    /// 我的关注数
99    #[serde(deserialize_with = "deserialize_i32_from_string_or_number")]
100    pub following: i32,
101    /// 等级信息
102    pub level_info: LevelInfo,
103    /// 用户 mid
104    #[serde(deserialize_with = "deserialize_i64_from_string_or_number")]
105    pub mid: i64,
106    /// 用户昵称
107    pub name: String,
108    /// 认证信息
109    #[serde(rename = "official")]
110    pub official: Official,
111    /// 个人空间背景图
112    pub space_bg: String,
113    /// 会员信息
114    pub vip: Vip,
115}
116
117/// 最近更新的 UP 主
118#[derive(Debug, Serialize, Clone, Deserialize)]
119pub struct UpUser {
120    /// 头像
121    pub face: String,
122    /// 是否有更新
123    pub has_update: bool,
124    /// 作用不明
125    pub is_reserve_recall: bool,
126    /// 用户 mid
127    #[serde(deserialize_with = "deserialize_i64_from_string_or_number")]
128    pub mid: i64,
129    /// 用户昵称
130    pub uname: String,
131}
132
133fn deserialize_i32_from_string_or_number<'de, D>(deserializer: D) -> Result<i32, D::Error>
134where
135    D: Deserializer<'de>,
136{
137    let value = serde_json::Value::deserialize(deserializer)?;
138    let value = parse_i64_from_string_or_number(value)?;
139    i32::try_from(value).map_err(|_| de::Error::custom("value must fit in i32"))
140}
141
142fn deserialize_i64_from_string_or_number<'de, D>(deserializer: D) -> Result<i64, D::Error>
143where
144    D: Deserializer<'de>,
145{
146    parse_i64_from_string_or_number(serde_json::Value::deserialize(deserializer)?)
147}
148
149fn parse_i64_from_string_or_number<E>(value: serde_json::Value) -> Result<i64, E>
150where
151    E: de::Error,
152{
153    match value {
154        serde_json::Value::Number(number) => number
155            .as_i64()
156            .ok_or_else(|| E::custom("value must be an integer")),
157        serde_json::Value::String(text) => text
158            .parse::<i64>()
159            .map_err(|_| E::custom("value must be a numeric string")),
160        _ => Err(E::custom("value must be a string or number")),
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::probe::contract::HttpMethod;
168    use crate::probe::endpoint_contract::EndpointContract;
169    use crate::{ApiEnvelope, BpiClient, BpiResult};
170
171    fn recent_up_contract() -> BpiResult<EndpointContract> {
172        EndpointContract::from_slice(include_bytes!(
173            "../../tests/contracts/dynamic/content/recent-up/contract.json"
174        ))
175    }
176
177    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
178    #[tokio::test]
179    async fn test_dynamic_recent_up_list() {
180        let bpi = BpiClient::new().expect("client should build");
181        let resp = bpi.dynamic().recent_up().await;
182        assert!(resp.is_ok());
183        if let Ok(data) = resp {
184            tracing::info!("{:#?}", data.up_list.len());
185        }
186    }
187
188    #[test]
189    fn dynamic_recent_up_contract_matches_endpoint_request() -> BpiResult<()> {
190        let contract = recent_up_contract()?;
191
192        assert_eq!(contract.name, "dynamic.recent_up");
193        assert_eq!(contract.request.method, HttpMethod::Get);
194        assert_eq!(
195            contract.request.url.as_str(),
196            "https://api.bilibili.com/x/polymer/web-dynamic/v1/portal"
197        );
198        assert!(contract.request.query.is_empty());
199        assert_eq!(contract.cases.len(), 3);
200        assert_eq!(
201            contract.cases[0].response.error.as_deref(),
202            Some("requires_login")
203        );
204        assert_eq!(
205            contract.cases[1].response.rust_model.as_deref(),
206            Some("RecentUpData")
207        );
208        Ok(())
209    }
210
211    #[test]
212    fn dynamic_recent_up_response_fixtures_parse_declared_model() -> BpiResult<()> {
213        for bytes in [
214            include_bytes!(
215                "../../tests/contracts/dynamic/content/recent-up/responses/normal.success.json"
216            )
217            .as_slice(),
218            include_bytes!(
219                "../../tests/contracts/dynamic/content/recent-up/responses/vip.success.json"
220            )
221            .as_slice(),
222        ] {
223            let payload = ApiEnvelope::<RecentUpData>::from_slice(bytes)?.into_payload()?;
224            let my_info = payload
225                .my_info
226                .expect("sanitized fixture should include my_info");
227            assert_eq!(my_info.dyns, 0);
228            assert_eq!(my_info.following, 0);
229            assert_eq!(my_info.mid, 1);
230        }
231        Ok(())
232    }
233
234    #[test]
235    fn dynamic_recent_up_anonymous_fixture_records_login_error() -> BpiResult<()> {
236        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
237            "../../tests/contracts/dynamic/content/recent-up/responses/anonymous.requires_login.json"
238        ))?
239        .ensure_success()
240        .unwrap_err();
241
242        assert_eq!(err.code(), Some(-101));
243        Ok(())
244    }
245
246    fn recent_up_local_probe_body(profile: &str) -> Option<serde_json::Value> {
247        let path = format!(
248            "target/bpi-probe-runs/dynamic/content-readonly/recent-up/{profile}.response.json"
249        );
250        local_probe_response_body(&path)
251    }
252
253    fn local_probe_response_body(path: &str) -> Option<serde_json::Value> {
254        let bytes = std::fs::read(path).ok()?;
255        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
256        value
257            .get("response")
258            .and_then(|response| response.get("body"))
259            .cloned()
260    }
261
262    #[test]
263    fn dynamic_recent_up_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
264        for profile in ["normal", "vip"] {
265            let Some(body) = recent_up_local_probe_body(profile) else {
266                continue;
267            };
268            let payload =
269                serde_json::from_value::<ApiEnvelope<RecentUpData>>(body)?.into_payload()?;
270            assert!(payload.my_info.is_some());
271        }
272
273        if let Some(body) = recent_up_local_probe_body("anonymous") {
274            let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
275                .ensure_success()
276                .unwrap_err();
277            assert_eq!(err.code(), Some(-101));
278        }
279        Ok(())
280    }
281}