1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Deserialize, Serialize)]
8pub struct Renew {
9 pub uid: u64,
11 pub ruid: u64,
13 pub goods_id: u64,
15 pub status: u8,
17 pub next_execute_time: u64,
19 pub signed_time: u64,
21 pub signed_price: u64,
23 pub pay_channel: u8,
25 pub period: u64,
27 pub mobile_app: String,
29}
30
31#[derive(Debug, Clone, Deserialize, Serialize)]
33pub struct ChargeItem {
34 pub privilege_type: u64,
36 pub icon: String,
38 pub name: String,
40 pub expire_time: u64,
42 pub renew: Option<Renew>,
44 pub start_time: u64,
46 pub renew_list: Option<Vec<Renew>>,
48}
49
50#[derive(Debug, Clone, Deserialize, Serialize)]
52pub struct ChargeUp {
53 pub up_uid: u64,
55 pub user_name: String,
57 pub user_face: String,
59 pub item: Vec<ChargeItem>,
61 pub start: u64,
63 pub high_level_state: u8,
65 pub elec_reply_state: u8,
67}
68
69#[derive(Debug, Clone, Deserialize, Serialize)]
71pub struct ChargeRecordData {
72 pub list: Option<Vec<ChargeUp>>,
74 pub page: u64,
76 pub page_size: u64,
78 pub total_page: u64,
80 pub total_num: u64,
82 pub is_more: u8,
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize)]
90pub struct UpowerRankUser {
91 pub rank: u64,
93 pub mid: u64,
95 pub nickname: String,
97 pub avatar: String,
99}
100
101#[derive(Debug, Clone, Deserialize, Serialize)]
103pub struct UpowerRank {
104 pub total: u64,
106 pub total_desc: String,
108 pub list: Vec<UpowerRankUser>,
110}
111
112#[derive(Debug, Clone, Deserialize, Serialize)]
114pub struct ItemDetailIntro {
115 pub intro_video_aid: String,
117 pub welcomes: String,
119}
120
121#[derive(Debug, Clone, Deserialize, Serialize)]
123pub struct UpUserCard {
124 pub avatar: String,
126 pub nickname: String,
128}
129
130#[derive(Debug, Clone, Deserialize, Serialize)]
132pub struct UpowerRightCount {
133 #[serde(flatten)]
134 pub counts: HashMap<String, u64>,
135}
136
137#[derive(Debug, Clone, Deserialize, Serialize)]
139pub struct UpowerItemDetail {
140 pub upower_rank: UpowerRank,
142 pub item: ItemDetailIntro,
144 pub user_card: UpUserCard,
146 pub upower_level: u8,
148 pub elec_reply_state: u8,
150 pub voucher_state: serde_json::Value,
152 pub upower_right_count: UpowerRightCount,
154 pub only_contain_medal: bool,
156 pub privilege_type: u64,
158}
159
160#[derive(Debug, Clone, Deserialize, Serialize)]
164pub struct UpCard {
165 pub mid: u64,
167 pub nickname: String,
169 pub official_title: String,
171 pub avatar: String,
173}
174
175#[derive(Debug, Clone, Deserialize, Serialize)]
177pub struct UserCard {
178 pub avatar: String,
180 pub nickname: String,
182}
183
184#[derive(Debug, Clone, Deserialize, Serialize)]
186pub struct ChargeFollowInfo {
187 pub days: u64,
189 pub up_card: UpCard,
191 pub user_card: UserCard,
193 pub remain_days: i64,
195 pub remain_less_1day: u8,
197 pub upower_rank: UpowerRank,
199 pub upower_icon: String,
201 pub upower_right_count: i64,
203 pub only_contain_medal: bool,
205 pub privilege_type: u64,
207 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#[derive(Debug, Clone, Deserialize, Serialize)]
228pub struct UpInfo {
229 pub mid: u64,
231 pub nickname: String,
233 pub avatar: String,
235 pub r#type: i32,
237 pub title: String,
239 pub upower_state: u8,
241}
242
243#[derive(Debug, Clone, Deserialize, Serialize)]
245pub struct RankInfo {
246 pub mid: u64,
248 pub nickname: String,
250 pub avatar: String,
252 pub rank: u64,
254 pub day: u64,
256 pub expire_at: u64,
258 pub remain_days: u64,
260}
261
262#[derive(Debug, Clone, Deserialize, Serialize)]
264pub struct MemberUserInfo {
265 pub mid: u64,
267 pub nickname: String,
269 pub avatar: String,
271 pub rank: i64,
273 pub day: u64,
275 pub expire_at: u64,
277 pub remain_days: u64,
279}
280
281#[derive(Debug, Clone, Deserialize, Serialize)]
283pub struct LevelInfo {
284 pub privilege_type: u64,
286 pub name: String,
288 pub price: u64,
290 pub member_total: u64,
292}
293
294#[derive(Debug, Clone, Deserialize, Serialize)]
296pub struct MemberRankData {
297 pub up_info: UpInfo,
299 pub rank_info: Vec<RankInfo>,
301 pub user_info: MemberUserInfo,
303 pub member_total: u64,
305 pub privilege_type: u64,
307 pub is_charge: bool,
309 pub tabs: Vec<u64>,
311 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 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 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 let up_mid = 1265680561;
406 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}