Skip to main content

bpi_rs/electric/
monthly.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4// --- Structs for `getChargeRecord` ---
5
6/// 充电自动续费详情
7#[derive(Debug, Clone, Deserialize, Serialize)]
8pub struct Renew {
9    /// 自己的mid
10    pub uid: u64,
11    /// UP主的mid
12    pub ruid: u64,
13    /// 充电类型 172:一个月 173:连续包月 174:连续包年
14    pub goods_id: u64,
15    /// 充电状态 1
16    pub status: u8,
17    /// 下次续费时间秒级时间戳
18    pub next_execute_time: u64,
19    /// 签约时间秒级时间戳
20    pub signed_time: u64,
21    /// 下次续费金额单位为千分之一元人民币
22    pub signed_price: u64,
23    /// 签约平台 2:微信支付 4:支付宝
24    pub pay_channel: u8,
25    /// 下次充电天数
26    pub period: u64,
27    /// 充电渠道
28    pub mobile_app: String,
29}
30
31/// 充电档位详情
32#[derive(Debug, Clone, Deserialize, Serialize)]
33pub struct ChargeItem {
34    /// 充电档位代码
35    pub privilege_type: u64,
36    /// 充电图标
37    pub icon: String,
38    /// 充电档位名称
39    pub name: String,
40    /// 该档位过期时间秒级时间戳
41    pub expire_time: u64,
42    /// 充电自动续费详情
43    pub renew: Option<Renew>,
44    /// 该档位生效时间秒级时间戳
45    pub start_time: u64,
46    /// 充电自动续费列表
47    pub renew_list: Option<Vec<Renew>>,
48}
49
50/// 包月充电UP主
51#[derive(Debug, Clone, Deserialize, Serialize)]
52pub struct ChargeUp {
53    /// 充电UP主mid
54    pub up_uid: u64,
55    /// 充电UP主昵称
56    pub user_name: String,
57    /// 充电UP主头像url
58    pub user_face: String,
59    /// 充电详情
60    pub item: Vec<ChargeItem>,
61    /// 开始充电时间秒级时间戳
62    pub start: u64,
63    /// 是否可对UP主进行高档充电
64    pub high_level_state: u8,
65    /// 是否可对UP主进行专属问答 0:否 1:是 2:状态未知
66    pub elec_reply_state: u8,
67}
68
69/// 包月充电列表数据
70#[derive(Debug, Clone, Deserialize, Serialize)]
71pub struct ChargeRecordData {
72    /// 包月充电UP主列表
73    pub list: Option<Vec<ChargeUp>>,
74    /// 当前页数
75    pub page: u64,
76    /// 当前分页大小
77    pub page_size: u64,
78    /// 总页数
79    pub total_page: u64,
80    /// 用户总数
81    pub total_num: u64,
82    /// 是否有更多用户 0:否 1:是
83    pub is_more: u8,
84}
85
86// --- Structs for `upower/item/detail` ---
87
88/// 充电用户排名
89#[derive(Debug, Clone, Deserialize, Serialize)]
90pub struct UpowerRankUser {
91    /// 充电用户索引
92    pub rank: u64,
93    /// 充电用户mid
94    pub mid: u64,
95    /// 充电用户昵称
96    pub nickname: String,
97    /// 充电用户头像url
98    pub avatar: String,
99}
100
101/// 充电详情
102#[derive(Debug, Clone, Deserialize, Serialize)]
103pub struct UpowerRank {
104    /// 充电用户总数
105    pub total: u64,
106    /// 充电总数文字说明
107    pub total_desc: String,
108    /// 充电用户列表
109    pub list: Vec<UpowerRankUser>,
110}
111
112/// 充电介绍
113#[derive(Debug, Clone, Deserialize, Serialize)]
114pub struct ItemDetailIntro {
115    /// 充电介绍视频AV号
116    pub intro_video_aid: String,
117    /// 充电介绍语
118    pub welcomes: String,
119}
120
121/// UP主信息卡片
122#[derive(Debug, Clone, Deserialize, Serialize)]
123pub struct UpUserCard {
124    /// UP主头像url
125    pub avatar: String,
126    /// UP主昵称
127    pub nickname: String,
128}
129
130/// 不同充电档位下的充电权益数
131#[derive(Debug, Clone, Deserialize, Serialize)]
132pub struct UpowerRightCount {
133    #[serde(flatten)]
134    pub counts: HashMap<String, u64>,
135}
136
137/// 包月充电详情数据
138#[derive(Debug, Clone, Deserialize, Serialize)]
139pub struct UpowerItemDetail {
140    /// 充电详情
141    pub upower_rank: UpowerRank,
142    /// 充电欢迎语信息
143    pub item: ItemDetailIntro,
144    /// UP主信息
145    pub user_card: UpUserCard,
146    /// UP主开通的充电等级 1:非高档充电 2:高档充电
147    pub upower_level: u8,
148    /// 是否可对UP主进行专属问答
149    pub elec_reply_state: u8,
150    /// 包月充电券信息
151    pub voucher_state: serde_json::Value,
152    /// 不同充电档位下的充电权益数
153    pub upower_right_count: UpowerRightCount,
154    /// 享有的权益仅为粉丝勋章
155    pub only_contain_medal: bool,
156    /// 当前给该UP主包月充电的档位
157    pub privilege_type: u64,
158}
159
160// --- Structs for `charge/follow/info` ---
161
162/// UP主信息卡片
163#[derive(Debug, Clone, Deserialize, Serialize)]
164pub struct UpCard {
165    /// UP主mid
166    pub mid: u64,
167    /// UP主昵称
168    pub nickname: String,
169    /// UP主认证信息
170    pub official_title: String,
171    /// UP主头像url
172    pub avatar: String,
173}
174
175/// 用户信息卡片
176#[derive(Debug, Clone, Deserialize, Serialize)]
177pub struct UserCard {
178    /// 用户头像url
179    pub avatar: String,
180    /// 用户昵称
181    pub nickname: String,
182}
183
184/// 与UP主的包月充电关系数据
185#[derive(Debug, Clone, Deserialize, Serialize)]
186pub struct ChargeFollowInfo {
187    /// 已保持多少天包月充电状态
188    pub days: u64,
189    /// UP主信息
190    pub up_card: UpCard,
191    /// 自己的信息
192    pub user_card: UserCard,
193    /// 剩余天数 未处于包月充电状态为-1
194    pub remain_days: i64,
195    /// 剩余的天数是否小于1天 0:否 1:是 未处于包月充电状态为0
196    pub remain_less_1day: u8,
197    /// 充电详情
198    pub upower_rank: UpowerRank,
199    /// 充电图标url 仅在处于包月充电状态时有内容
200    pub upower_icon: String,
201    /// 当前自己享有该UP主的充电权益数
202    pub upower_right_count: i64,
203    /// 享有的权益仅为粉丝勋章
204    pub only_contain_medal: bool,
205    /// 当前给该UP主包月充电的档位代码
206    pub privilege_type: u64,
207    /// 充电挑战信息
208    pub challenge_info: ChallengeInfo,
209}
210
211#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
212pub struct ChallengeInfo {
213    pub challenge_id: String,
214    pub description: String,
215    pub challenge_type: i64,
216    pub remaining_days: i64,
217    pub end_time: String,
218    pub progress: i64,
219    pub targets: Vec<serde_json::Value>,
220    pub state: i64,
221    pub end_time_unix: i64,
222    pub pub_dyn: i64,
223    pub dyn_content: String,
224}
225
226/// UP主信息
227#[derive(Debug, Clone, Deserialize, Serialize)]
228pub struct UpInfo {
229    /// UP主mid
230    pub mid: u64,
231    /// UP主昵称
232    pub nickname: String,
233    /// UP主头像url
234    pub avatar: String,
235    /// UP主认证类型
236    pub r#type: i32,
237    /// UP主认证文字
238    pub title: String,
239    /// UP主充电功能开启状态
240    pub upower_state: u8,
241}
242
243/// 充电用户排名
244#[derive(Debug, Clone, Deserialize, Serialize)]
245pub struct RankInfo {
246    /// 充电用户mid
247    pub mid: u64,
248    /// 充电用户昵称
249    pub nickname: String,
250    /// 充电用户头像url
251    pub avatar: String,
252    /// 充电用户排名
253    pub rank: u64,
254    /// 包月充电天数
255    pub day: u64,
256    /// 包月充电过期时间恒为0
257    pub expire_at: u64,
258    /// 剩余天数恒为0
259    pub remain_days: u64,
260}
261
262/// 自己的充电关系信息
263#[derive(Debug, Clone, Deserialize, Serialize)]
264pub struct MemberUserInfo {
265    /// 用户mid
266    pub mid: u64,
267    /// 用户昵称
268    pub nickname: String,
269    /// 用户头像url
270    pub avatar: String,
271    /// 包月充电排名
272    pub rank: i64,
273    /// 包月充电天数
274    pub day: u64,
275    /// 包月充电过期时间秒级时间戳
276    pub expire_at: u64,
277    /// 剩余天数
278    pub remain_days: u64,
279}
280
281/// 充电档位信息
282#[derive(Debug, Clone, Deserialize, Serialize)]
283pub struct LevelInfo {
284    /// 充电档位代码
285    pub privilege_type: u64,
286    /// 档位名称
287    pub name: String,
288    /// 档位价格单位为百分之一元人民币
289    pub price: u64,
290    /// 当前档位的用户总数
291    pub member_total: u64,
292}
293
294/// 包月充电用户排名数据
295#[derive(Debug, Clone, Deserialize, Serialize)]
296pub struct MemberRankData {
297    /// UP主信息
298    pub up_info: UpInfo,
299    /// 当前档位的充电用户排名
300    pub rank_info: Vec<RankInfo>,
301    /// 自己在该档位下与UP主的充电关系
302    pub user_info: MemberUserInfo,
303    /// 当前档位充电用户总数
304    pub member_total: u64,
305    /// 当前充电档位代码
306    pub privilege_type: u64,
307    /// 自己是否给该UP主包月充电过
308    pub is_charge: bool,
309    /// 可显示排名的充电档位代码列表
310    pub tabs: Vec<u64>,
311    /// 可显示排名的充电档位信息
312    pub level_info: Vec<LevelInfo>,
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::probe::contract::HttpMethod;
319    use crate::probe::endpoint_contract::EndpointContract;
320    use crate::{ApiEnvelope, BpiClient, BpiResult};
321    use tracing::info;
322
323    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
324        let bytes = match endpoint {
325            "upower-item-detail" => include_bytes!(
326                "../../tests/contracts/electric/public-read/upower-item-detail/contract.json"
327            )
328            .as_slice(),
329            "upower-member-rank" => include_bytes!(
330                "../../tests/contracts/electric/public-read/upower-member-rank/contract.json"
331            )
332            .as_slice(),
333            "charge-record" => include_bytes!(
334                "../../tests/contracts/electric/private-read/charge-record/contract.json"
335            )
336            .as_slice(),
337            "charge-follow-info" => include_bytes!(
338                "../../tests/contracts/electric/private-read/charge-follow-info/contract.json"
339            )
340            .as_slice(),
341            _ => unreachable!("unknown electric monthly contract endpoint"),
342        };
343
344        EndpointContract::from_slice(bytes)
345    }
346
347    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
348    #[tokio::test]
349    async fn test_get_charge_record() {
350        let bpi = BpiClient::new().expect("client should build");
351        // 获取自己使用中的包月充电列表
352        let resp = bpi.electric().charge_record(1, 1).await;
353        info!("响应: {:?}", resp);
354        assert!(resp.is_ok());
355
356        if let Ok(data) = resp {
357            if let Some(list) = data.list {
358                info!("找到 {} 个正在充电的UP主", list.len());
359            } else {
360                info!("没有正在充电的UP主");
361            }
362        }
363    }
364
365    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
366    #[tokio::test]
367    async fn test_get_upower_item_detail() {
368        let bpi = BpiClient::new().expect("client should build");
369        // 替换为有效的UP主mid
370        let up_mid = 1265680561;
371        let resp = bpi.electric().upower_item_detail(up_mid).await;
372        info!("响应: {:?}", resp);
373        assert!(resp.is_ok());
374
375        if let Ok(data) = resp {
376            info!(
377                "UP主 {} 的充电总人数: {}",
378                data.user_card.nickname, data.upower_rank.total
379            );
380        }
381    }
382
383    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
384    #[tokio::test]
385    async fn test_get_charge_follow_info() {
386        let bpi = BpiClient::new().expect("client should build");
387        let up_mid = 293793435;
388        let resp = bpi.electric().charge_follow_info(up_mid).await;
389        info!("响应: {:?}", resp);
390        assert!(resp.is_ok());
391
392        if let Ok(data) = resp {
393            info!(
394                "与UP主 {} 的充电关系:已保持 {} 天",
395                data.up_card.nickname, data.days
396            );
397        }
398    }
399
400    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
401    #[tokio::test]
402    async fn test_get_upower_member_rank() {
403        let bpi = BpiClient::new().expect("client should build");
404        // 替换为有效的UP主mid
405        let up_mid = 1265680561;
406        // 获取所有档位的用户排名
407        let resp = bpi.electric().upower_member_rank(up_mid, 1, 10, None).await;
408        info!("响应: {:?}", resp);
409        assert!(resp.is_ok());
410
411        if let Ok(data) = resp {
412            info!("当前档位充电用户总数: {}", data.member_total);
413            if let Some(first_rank) = data.rank_info.first() {
414                info!("排名第一的用户: {}", first_rank.nickname);
415            }
416        }
417    }
418
419    #[test]
420    fn electric_upower_item_detail_contract_matches_endpoint_request() -> BpiResult<()> {
421        let contract = contract("upower-item-detail")?;
422
423        assert_eq!(contract.name, "electric.upower_item_detail");
424        assert_eq!(contract.request.method, HttpMethod::Get);
425        assert_eq!(
426            contract.request.url.as_str(),
427            "https://api.bilibili.com/x/upower/item/detail"
428        );
429        assert_eq!(
430            contract.request.query.get("up_mid").map(String::as_str),
431            Some("1265680561")
432        );
433        assert_eq!(contract.cases.len(), 3);
434        assert_eq!(
435            contract.cases[0].response.rust_model.as_deref(),
436            Some("UpowerItemDetail")
437        );
438        Ok(())
439    }
440
441    #[test]
442    fn electric_upower_member_rank_contract_matches_endpoint_request() -> BpiResult<()> {
443        let contract = contract("upower-member-rank")?;
444
445        assert_eq!(contract.name, "electric.upower_member_rank");
446        assert_eq!(contract.request.method, HttpMethod::Get);
447        assert_eq!(
448            contract.request.url.as_str(),
449            "https://api.bilibili.com/x/upower/up/member/rank/v2"
450        );
451        assert_eq!(
452            contract.request.query.get("up_mid").map(String::as_str),
453            Some("1265680561")
454        );
455        assert_eq!(
456            contract.request.query.get("pn").map(String::as_str),
457            Some("1")
458        );
459        assert_eq!(
460            contract.request.query.get("ps").map(String::as_str),
461            Some("10")
462        );
463        assert_eq!(contract.cases.len(), 3);
464        assert_eq!(
465            contract.cases[0].response.rust_model.as_deref(),
466            Some("MemberRankData")
467        );
468        Ok(())
469    }
470
471    #[test]
472    fn electric_charge_record_contract_matches_endpoint_request() -> BpiResult<()> {
473        let contract = contract("charge-record")?;
474
475        assert_eq!(contract.name, "electric.charge_record");
476        assert_eq!(contract.request.method, HttpMethod::Get);
477        assert_eq!(
478            contract.request.url.as_str(),
479            "https://api.live.bilibili.com/xlive/revenue/v1/guard/getChargeRecord"
480        );
481        assert_eq!(
482            contract.request.query.get("page").map(String::as_str),
483            Some("1")
484        );
485        assert_eq!(
486            contract.request.query.get("type").map(String::as_str),
487            Some("1")
488        );
489        assert_eq!(contract.cases.len(), 3);
490        assert_eq!(
491            contract.cases[1].response.rust_model.as_deref(),
492            Some("ChargeRecordData")
493        );
494        Ok(())
495    }
496
497    #[test]
498    fn electric_charge_follow_info_contract_matches_endpoint_request() -> BpiResult<()> {
499        let contract = contract("charge-follow-info")?;
500
501        assert_eq!(contract.name, "electric.charge_follow_info");
502        assert_eq!(contract.request.method, HttpMethod::Get);
503        assert_eq!(
504            contract.request.url.as_str(),
505            "https://api.bilibili.com/x/upower/charge/follow/info"
506        );
507        assert_eq!(
508            contract.request.query.get("up_mid").map(String::as_str),
509            Some("1265680561")
510        );
511        assert_eq!(contract.cases.len(), 3);
512        assert_eq!(
513            contract.cases[1].response.rust_model.as_deref(),
514            Some("ChargeFollowInfo")
515        );
516        Ok(())
517    }
518
519    #[test]
520    fn electric_monthly_response_fixtures_parse_declared_models() -> BpiResult<()> {
521        let item_detail = ApiEnvelope::<UpowerItemDetail>::from_slice(include_bytes!(
522            "../../tests/contracts/electric/public-read/upower-item-detail/responses/success.json"
523        ))?
524        .into_payload()?;
525        assert_eq!(item_detail.upower_rank.list.len(), 1);
526        assert_eq!(item_detail.upower_right_count.counts["100"], 5);
527
528        let anonymous_rank = ApiEnvelope::<MemberRankData>::from_slice(include_bytes!(
529            "../../tests/contracts/electric/public-read/upower-member-rank/responses/anonymous.success.json"
530        ))?
531        .into_payload()?;
532        assert_eq!(anonymous_rank.user_info.mid, 0);
533
534        let authenticated_rank = ApiEnvelope::<MemberRankData>::from_slice(include_bytes!(
535            "../../tests/contracts/electric/public-read/upower-member-rank/responses/authenticated.success.json"
536        ))?
537        .into_payload()?;
538        assert_eq!(authenticated_rank.user_info.mid, 1);
539        Ok(())
540    }
541
542    #[test]
543    fn electric_monthly_private_response_fixtures_parse_declared_models() -> BpiResult<()> {
544        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
545            "../../tests/contracts/electric/private-read/charge-record/responses/anonymous.requires_login.json"
546        ))?
547        .ensure_success()
548        .unwrap_err();
549        assert!(err.requires_login());
550
551        let charge_record = ApiEnvelope::<ChargeRecordData>::from_slice(include_bytes!(
552            "../../tests/contracts/electric/private-read/charge-record/responses/authenticated.success.json"
553        ))?
554        .into_payload()?;
555        assert_eq!(charge_record.total_num, 0);
556
557        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
558            "../../tests/contracts/electric/private-read/charge-follow-info/responses/anonymous.requires_login.json"
559        ))?
560        .ensure_success()
561        .unwrap_err();
562        assert!(err.requires_login());
563
564        let follow_info = ApiEnvelope::<ChargeFollowInfo>::from_slice(include_bytes!(
565            "../../tests/contracts/electric/private-read/charge-follow-info/responses/authenticated.success.json"
566        ))?
567        .into_payload()?;
568        assert_eq!(follow_info.up_card.mid, 1265680561);
569        Ok(())
570    }
571
572    fn local_probe_body(batch: &str, endpoint: &str, profile: &str) -> Option<serde_json::Value> {
573        let path =
574            format!("target/bpi-probe-runs/electric/{batch}/{endpoint}/{profile}.response.json");
575        let bytes = std::fs::read(path).ok()?;
576        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
577        value
578            .get("response")
579            .and_then(|response| response.get("body"))
580            .cloned()
581    }
582
583    #[test]
584    fn electric_monthly_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
585        for profile in ["anonymous", "normal", "vip"] {
586            if let Some(body) = local_probe_body("public-read", "upower-item-detail", profile) {
587                let payload = serde_json::from_value::<ApiEnvelope<UpowerItemDetail>>(body)?
588                    .into_payload()?;
589                assert!(payload.upower_rank.total >= payload.upower_rank.list.len() as u64);
590            }
591
592            if let Some(body) = local_probe_body("public-read", "upower-member-rank", profile) {
593                let payload =
594                    serde_json::from_value::<ApiEnvelope<MemberRankData>>(body)?.into_payload()?;
595                assert!(payload.member_total >= payload.rank_info.len() as u64);
596            }
597        }
598        Ok(())
599    }
600
601    #[test]
602    fn electric_monthly_private_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
603        for profile in ["anonymous", "normal", "vip"] {
604            if let Some(body) = local_probe_body("private-read", "charge-record", profile) {
605                let envelope = serde_json::from_value::<ApiEnvelope<ChargeRecordData>>(body)?;
606                if profile == "anonymous" {
607                    let err = envelope.ensure_success().unwrap_err();
608                    assert!(err.requires_login());
609                } else {
610                    let payload = envelope.into_payload()?;
611                    assert!(payload.total_num >= payload.list.as_ref().map_or(0, Vec::len) as u64);
612                }
613            }
614
615            if let Some(body) = local_probe_body("private-read", "charge-follow-info", profile) {
616                let envelope = serde_json::from_value::<ApiEnvelope<ChargeFollowInfo>>(body)?;
617                if profile == "anonymous" {
618                    let err = envelope.ensure_success().unwrap_err();
619                    assert!(err.requires_login());
620                } else {
621                    let payload = envelope.into_payload()?;
622                    assert!(payload.upower_rank.total >= payload.upower_rank.list.len() as u64);
623                }
624            }
625        }
626        Ok(())
627    }
628}