Skip to main content

bpi_rs/live/
gift.rs

1use serde::{Deserialize, Serialize};
2
3// ================= 数据结构 =================
4
5#[derive(Debug, Serialize, Clone, Deserialize)]
6pub struct GiftItem {
7    /// 礼物id
8    pub id: i64,
9    /// 礼物名字
10    pub name: String,
11    /// 价格(该值/1000的单位为元)
12    pub price: i64,
13    /// 类型
14    pub r#type: i32,
15    /// 货币类型(一般为gold,即电池)
16    pub coin_type: String,
17    /// 特效类型
18    pub effect: i32,
19    /// 礼物展示的时间
20    pub stay_time: i32,
21    /// 礼物动画帧数
22    pub animation_frame_num: i32,
23    /// 礼物描述
24    pub desc: String,
25    /// 礼物图片
26    pub img_basic: String,
27    /// 礼物gif动画
28    pub gif: String,
29}
30
31#[derive(Debug, Serialize, Clone, Deserialize)]
32pub struct GiftConfig {
33    /// 礼物列表
34    pub list: Vec<GiftItem>,
35}
36
37#[derive(Debug, Serialize, Clone, Deserialize)]
38pub struct GiftBaseConfig {
39    /// 基础配置
40    pub base_config: GiftConfig,
41}
42
43#[derive(Debug, Serialize, Clone, Deserialize)]
44pub struct RoomGiftData {
45    /// 礼物配置
46    pub gift_config: Option<GiftBaseConfig>,
47    /// 新版礼物面板数据
48    pub gift_data: Option<serde_json::Value>,
49    /// 全局配置
50    pub global_config: Option<serde_json::Value>,
51}
52
53#[derive(Debug, Serialize, Clone, Deserialize)]
54pub struct BlindGiftItem {
55    /// 爆出的礼物id
56    pub gift_id: i64,
57    /// 爆出的礼物价格
58    pub price: i64,
59    /// 礼物名字
60    pub gift_name: String,
61    /// 礼物图片
62    pub gift_img: String,
63    /// 概率
64    pub chance: String,
65}
66
67#[derive(Debug, Serialize, Clone, Deserialize)]
68pub struct BlindGiftData {
69    /// 描述
70    pub note_text: String,
71    /// 盲盒价格
72    pub blind_price: i64,
73    /// 盲盒名字
74    pub blind_gift_name: String,
75    /// 盲盒礼物列表
76    pub gifts: Vec<BlindGiftItem>,
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::probe::contract::HttpMethod;
83    use crate::probe::endpoint_contract::EndpointContract;
84    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
85
86    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
87        let bytes = match endpoint {
88            "room-gift-list" => {
89                include_bytes!("../../tests/contracts/live/gift-read/room-gift-list/contract.json")
90                    .as_slice()
91            }
92            "blind-gift-info" => {
93                include_bytes!("../../tests/contracts/live/gift-read/blind-gift-info/contract.json")
94                    .as_slice()
95            }
96            _ => unreachable!("unknown live gift contract endpoint"),
97        };
98
99        EndpointContract::from_slice(bytes)
100    }
101
102    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
103    #[tokio::test]
104    async fn test_get_room_gift_list() -> Result<(), Box<BpiError>> {
105        let bpi = BpiClient::new().expect("client should build");
106        let data = bpi.live().room_gift_list(23174842, None, None).await?;
107
108        if let Some(gift_config) = data.gift_config {
109            assert!(!gift_config.base_config.list.is_empty());
110        } else {
111            assert!(data.gift_data.is_some());
112        }
113        Ok(())
114    }
115
116    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
117    #[tokio::test]
118    async fn test_get_blind_gift_info() -> Result<(), Box<BpiError>> {
119        let bpi = BpiClient::new().expect("client should build");
120        let data = bpi.live().blind_gift_info(32251).await?;
121
122        assert!(!data.gifts.is_empty());
123        Ok(())
124    }
125
126    #[test]
127    fn live_room_gift_list_contract_matches_endpoint_request() -> BpiResult<()> {
128        let contract = contract("room-gift-list")?;
129
130        assert_eq!(contract.name, "live.room_gift_list");
131        assert_eq!(contract.request.method, HttpMethod::Get);
132        assert_eq!(
133            contract.request.url.as_str(),
134            "https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftList"
135        );
136        assert_eq!(
137            contract.request.query.get("room_id").map(String::as_str),
138            Some("23174842")
139        );
140        assert_eq!(
141            contract.request.query.get("platform").map(String::as_str),
142            Some("web")
143        );
144        assert_eq!(contract.cases.len(), 3);
145        assert_eq!(
146            contract.cases[0].response.rust_model.as_deref(),
147            Some("RoomGiftData")
148        );
149        Ok(())
150    }
151
152    #[test]
153    fn live_blind_gift_info_contract_matches_endpoint_request() -> BpiResult<()> {
154        let contract = contract("blind-gift-info")?;
155
156        assert_eq!(contract.name, "live.blind_gift_info");
157        assert_eq!(contract.request.method, HttpMethod::Get);
158        assert_eq!(
159            contract.request.url.as_str(),
160            "https://api.live.bilibili.com/xlive/general-interface/v1/blindFirstWin/getInfo"
161        );
162        assert_eq!(
163            contract.request.query.get("gift_id").map(String::as_str),
164            Some("32251")
165        );
166        assert_eq!(contract.cases.len(), 3);
167        assert_eq!(contract.cases[0].response.api_code, Some(-101));
168        assert_eq!(
169            contract.cases[1].response.rust_model.as_deref(),
170            Some("BlindGiftData")
171        );
172        Ok(())
173    }
174
175    #[test]
176    fn live_gift_response_fixtures_parse_declared_models() -> BpiResult<()> {
177        let room_gift = ApiEnvelope::<RoomGiftData>::from_slice(include_bytes!(
178            "../../tests/contracts/live/gift-read/room-gift-list/responses/success.json"
179        ))?
180        .into_payload()?;
181        assert!(room_gift.gift_config.is_none());
182        assert!(room_gift.gift_data.is_some());
183
184        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
185            "../../tests/contracts/live/gift-read/blind-gift-info/responses/anonymous.requires_login.json"
186        ))?
187        .ensure_success()
188        .unwrap_err();
189        assert!(err.requires_login());
190
191        let blind = ApiEnvelope::<BlindGiftData>::from_slice(include_bytes!(
192            "../../tests/contracts/live/gift-read/blind-gift-info/responses/authenticated.success.json"
193        ))?
194        .into_payload()?;
195        assert_eq!(blind.gifts.len(), 1);
196        Ok(())
197    }
198
199    fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
200        let path =
201            format!("target/bpi-probe-runs/live/gift-read/{endpoint}/{profile}.response.json");
202        let bytes = std::fs::read(path).ok()?;
203        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
204        value
205            .get("response")
206            .and_then(|response| response.get("body"))
207            .cloned()
208    }
209
210    #[test]
211    fn live_gift_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
212        for profile in ["anonymous", "normal", "vip"] {
213            if let Some(body) = local_probe_body("room-gift-list", profile) {
214                let payload =
215                    serde_json::from_value::<ApiEnvelope<RoomGiftData>>(body)?.into_payload()?;
216                assert!(payload.gift_config.is_some() || payload.gift_data.is_some());
217            }
218
219            if let Some(body) = local_probe_body("blind-gift-info", profile) {
220                let envelope = serde_json::from_value::<ApiEnvelope<BlindGiftData>>(body)?;
221                if profile == "anonymous" {
222                    let err = envelope.ensure_success().unwrap_err();
223                    assert!(err.requires_login());
224                } else {
225                    let payload = envelope.into_payload()?;
226                    assert!(!payload.gifts.is_empty());
227                }
228            }
229        }
230        Ok(())
231    }
232}