1use crate::models::{Official, Pendant, Vip};
2use serde::{Deserialize, Serialize};
3#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct DynamicDetailData {
8 pub item: DynamicDetailItem,
9}
10
11#[derive(Debug, Clone, Deserialize, Serialize)]
13pub struct DynamicDetailItem {
14 pub id_str: String,
15 pub basic: DynamicBasic,
16
17 pub modules: serde_json::Value,
18
19 pub orig: Option<Box<DynamicDetailItem>>,
21
22 pub r#type: String,
23
24 pub visible: bool,
25}
26
27#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct DynamicForwardItem {
30 pub desc: Desc,
31 pub id_str: String,
32 pub pub_time: String,
33 pub user: User,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Desc {
38 pub rich_text_nodes: Vec<RichTextNode>,
39 pub text: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct RichTextNode {
44 pub orig_text: String,
45 pub text: String,
46 #[serde(rename = "type")]
47 pub type_field: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct User {
52 pub face: String,
53 pub face_nft: bool,
54 pub mid: i64,
55 pub name: String,
56 pub official: Official,
57 pub pendant: Pendant,
58 pub vip: Vip,
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct DynamicBasic {
63 pub comment_id_str: String,
64 pub comment_type: i64,
65 pub editable: Option<bool>,
66 pub jump_url: Option<String>,
67 pub like_icon: serde_json::Value,
68 pub rid_str: String,
69}
70
71#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct DynamicReactionItem {
76 pub action: String,
77 pub attend: u8,
79 pub desc: String,
80 pub face: String,
81 pub mid: String,
82 pub name: String,
83}
84
85#[derive(Debug, Clone, Deserialize, Serialize)]
87pub struct DynamicReactionData {
88 pub has_more: bool,
89 pub items: Vec<DynamicReactionItem>,
90 pub offset: String,
91 pub total: u64,
92}
93
94#[derive(Debug, Clone, Deserialize, Serialize)]
98pub struct LotteryWinner {
99 pub uid: u64,
100 pub name: String,
101 pub face: String,
102 pub hongbao_money: Option<f64>,
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct DynamicLotteryResult {
108 #[serde(default)]
109 pub first_prize_result: Vec<LotteryWinner>,
110 #[serde(default)]
111 pub second_prize_result: Vec<LotteryWinner>,
112 #[serde(default)]
113 pub third_prize_result: Vec<LotteryWinner>,
114}
115
116#[derive(Debug, Clone, Deserialize, Serialize)]
118pub struct DynamicLotteryPrizeType {
119 #[serde(rename = "type")]
120 pub type_field: u8,
121 pub value: DynamicLotteryPrizeTypeValue,
122}
123
124#[derive(Debug, Clone, Deserialize, Serialize)]
125pub struct DynamicLotteryPrizeTypeValue {
126 pub count: u64,
127 pub stype: u8,
128}
129
130#[derive(Debug, Clone, Deserialize, Serialize)]
132pub struct DynamicLotteryData {
133 pub lottery_id: u64,
134 pub sender_uid: u64,
135 pub business_type: u8,
136 pub business_id: u64,
137 pub status: u8,
138 pub lottery_time: u64,
139 pub participants: u64,
140 pub first_prize: u32,
141 pub first_prize_cmt: String,
142 pub first_prize_pic: String,
143 pub second_prize: u32,
144 #[serde(default)]
145 pub second_prize_cmt: Option<String>,
146 pub second_prize_pic: String,
147 pub third_prize: u32,
148 #[serde(default)]
149 pub third_prize_cmt: Option<String>,
150 pub third_prize_pic: String,
151 pub lottery_result: Option<DynamicLotteryResult>,
152 pub followed: bool,
153 pub has_charge_right: bool,
154 pub lottery_at_num: u32,
155 pub lottery_detail_url: String,
156 pub lottery_feed_limit: u32,
157 pub need_post: u8,
158 pub participated: bool,
159 #[serde(default)]
160 pub prize_type_first: Option<DynamicLotteryPrizeType>,
161 pub reposted: bool,
162 pub ts: u64,
163 pub upower_redirect_url: String,
164 pub vip_batch_sign: String,
165 pub vip_redirect_url: String,
166}
167
168#[derive(Debug, Clone, Deserialize, Serialize)]
172pub struct DynamicForwardData {
173 pub has_more: bool,
174 pub items: Vec<DynamicForwardItem>,
175 pub offset: String,
176 pub total: u64,
177}
178#[derive(Debug, Clone, Deserialize, Serialize)]
179pub struct DynamicForwardInfoData {
180 pub item: DynamicForwardItem,
181}
182
183#[derive(Debug, Clone, Deserialize, Serialize)]
187pub struct DynamicPic {
188 pub height: u64,
189 pub size: f64,
190 pub src: String,
191 pub width: u64,
192}
193
194#[derive(Debug, Clone, Deserialize, Serialize)]
196pub struct DynamicPicsData {
197 pub data: Vec<DynamicPic>,
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::dynamic::{
204 DynamicDetailParams, DynamicForwardItemParams, DynamicForwardsParams,
205 DynamicLotteryNoticeParams, DynamicPicsParams, DynamicReactionsParams,
206 };
207 use crate::ids::DynamicId;
208 use crate::probe::contract::HttpMethod;
209 use crate::probe::endpoint_contract::EndpointContract;
210 use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
211 use std::collections::BTreeMap;
212 use tracing::info;
213
214 fn parse_dynamic_id(value: &str) -> Result<DynamicId, BpiError> {
215 value.parse()
216 }
217
218 fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
219 let bytes = match endpoint {
220 "detail" => include_bytes!("../../tests/contracts/dynamic/detail/detail/contract.json")
221 .as_slice(),
222 "reactions" => {
223 include_bytes!("../../tests/contracts/dynamic/detail/reactions/contract.json")
224 .as_slice()
225 }
226 "forwards" => {
227 include_bytes!("../../tests/contracts/dynamic/detail/forwards/contract.json")
228 .as_slice()
229 }
230 "pics" => {
231 include_bytes!("../../tests/contracts/dynamic/detail/pics/contract.json").as_slice()
232 }
233 "forward-item" => {
234 include_bytes!("../../tests/contracts/dynamic/detail/forward-item/contract.json")
235 .as_slice()
236 }
237 "lottery-notice" => include_bytes!(
238 "../../tests/contracts/dynamic/lottery-notice-read/lottery-notice/contract.json"
239 )
240 .as_slice(),
241 _ => unreachable!("unknown dynamic detail endpoint"),
242 };
243
244 EndpointContract::from_slice(bytes)
245 }
246
247 fn query_map<I>(query: I) -> BTreeMap<String, String>
248 where
249 I: IntoIterator<Item = (&'static str, String)>,
250 {
251 query
252 .into_iter()
253 .map(|(key, value)| (key.to_string(), value))
254 .collect()
255 }
256
257 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
258 #[tokio::test]
259 async fn test_get_dynamic_detail() -> Result<(), BpiError> {
260 let bpi = BpiClient::new().expect("client should build");
261 let dynamic_id = "1099138163191840776";
262 let data = bpi
263 .dynamic()
264 .detail(DynamicDetailParams::new(parse_dynamic_id(dynamic_id)?))
265 .await?;
266
267 info!("动态详情: {:?}", data.item);
268 assert_eq!(data.item.id_str, dynamic_id);
269
270 let dynamic_id = "1152614216889270274"; let data = bpi
272 .dynamic()
273 .detail(DynamicDetailParams::new(parse_dynamic_id(dynamic_id)?))
274 .await?;
275 info!("动态详情: {:?}", data.item);
276 assert_eq!(data.item.id_str, dynamic_id);
277 assert!(data.item.orig.is_some());
278
279 Ok(())
280 }
281
282 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
283 #[tokio::test]
284 async fn test_get_dynamic_reactions() -> Result<(), BpiError> {
285 let bpi = BpiClient::new().expect("client should build");
286 let dynamic_id = "1099138163191840776";
287 let data = bpi
288 .dynamic()
289 .reactions(DynamicReactionsParams::new(parse_dynamic_id(dynamic_id)?))
290 .await?;
291
292 info!("点赞/转发总数: {}", data.total);
293 assert!(!data.items.is_empty());
294
295 Ok(())
296 }
297
298 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
299 #[tokio::test]
300 async fn test_get_lottery_notice() -> Result<(), BpiError> {
301 let bpi = BpiClient::new().expect("client should build");
302 let dynamic_id = "969916293954142214";
303 let data = bpi
304 .dynamic()
305 .lottery_notice(DynamicLotteryNoticeParams::new(parse_dynamic_id(
306 dynamic_id,
307 )?))
308 .await?;
309
310 info!("抽奖状态: {}", data.status);
311 assert_eq!(data.business_id.to_string(), dynamic_id);
312
313 Ok(())
314 }
315
316 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
317 #[tokio::test]
318 async fn test_get_dynamic_forwards() -> Result<(), BpiError> {
319 let bpi = BpiClient::new().expect("client should build");
320 let dynamic_id = "1099138163191840776";
321 let data = bpi
322 .dynamic()
323 .forwards(DynamicForwardsParams::new(parse_dynamic_id(dynamic_id)?))
324 .await?;
325
326 info!("转发总数: {}", data.total);
327 assert!(!data.items.is_empty());
328
329 Ok(())
330 }
331
332 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
333 #[tokio::test]
334 async fn test_get_dynamic_pics() -> Result<(), BpiError> {
335 let bpi = BpiClient::new().expect("client should build");
336 let dynamic_id = "1099138163191840776";
337 let data = bpi
338 .dynamic()
339 .pics(DynamicPicsParams::new(parse_dynamic_id(dynamic_id)?))
340 .await?;
341
342 info!("图片数量: {}", data.len());
343 assert!(!data.is_empty());
344
345 Ok(())
346 }
347
348 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
349 #[tokio::test]
350 async fn test_get_forward_item() -> Result<(), BpiError> {
351 let bpi = BpiClient::new().expect("client should build");
352 let dynamic_id = "1110902525317349376";
353 let data = bpi
354 .dynamic()
355 .forward_item(DynamicForwardItemParams::new(parse_dynamic_id(dynamic_id)?))
356 .await?;
357
358 info!("转发动态详情: {:?}", data.item);
359 assert_eq!(data.item.id_str, dynamic_id);
360
361 Ok(())
362 }
363
364 #[test]
365 fn dynamic_detail_params_serializes_default_features() -> Result<(), BpiError> {
366 let params = DynamicDetailParams::new(parse_dynamic_id("1099138163191840776")?);
367
368 assert_eq!(
369 params.query_pairs(),
370 [
371 ("id", "1099138163191840776".to_string()),
372 (
373 "features",
374 "htmlNewStyle,itemOpusStyle,decorationCard".to_string()
375 ),
376 ]
377 );
378 Ok(())
379 }
380
381 #[test]
382 fn dynamic_detail_params_serializes_custom_features() -> Result<(), BpiError> {
383 let params = DynamicDetailParams::new(parse_dynamic_id("1099138163191840776")?)
384 .with_features("itemOpusStyle,opusBigCover")?;
385
386 assert_eq!(
387 params.query_pairs(),
388 [
389 ("id", "1099138163191840776".to_string()),
390 ("features", "itemOpusStyle,opusBigCover".to_string()),
391 ]
392 );
393 Ok(())
394 }
395
396 #[test]
397 fn dynamic_reactions_params_serializes_offset() -> Result<(), BpiError> {
398 let params = DynamicReactionsParams::new(parse_dynamic_id("1099138163191840776")?)
399 .with_offset("offset-token")?;
400
401 assert_eq!(
402 params.query_pairs(),
403 [
404 ("id", "1099138163191840776".to_string()),
405 ("offset", "offset-token".to_string()),
406 ]
407 );
408 Ok(())
409 }
410
411 #[test]
412 fn dynamic_lottery_notice_params_serializes_csrf_query() -> Result<(), BpiError> {
413 let params = DynamicLotteryNoticeParams::new(parse_dynamic_id("969916293954142214")?);
414
415 assert_eq!(
416 params.query_pairs("csrf-token"),
417 [
418 ("business_id", "969916293954142214".to_string()),
419 ("business_type", "1".to_string()),
420 ("csrf", "csrf-token".to_string()),
421 ]
422 );
423 Ok(())
424 }
425
426 #[test]
427 fn dynamic_pics_params_serializes_query() -> Result<(), BpiError> {
428 let params = DynamicPicsParams::new(parse_dynamic_id("1099138163191840776")?);
429
430 assert_eq!(
431 params.query_pairs(),
432 [("id", "1099138163191840776".to_string())]
433 );
434 Ok(())
435 }
436
437 #[test]
438 fn dynamic_forward_item_params_serializes_query() -> Result<(), BpiError> {
439 let params = DynamicForwardItemParams::new(parse_dynamic_id("1110902525317349376")?);
440
441 assert_eq!(
442 params.query_pairs(),
443 [("id", "1110902525317349376".to_string())]
444 );
445 Ok(())
446 }
447
448 #[test]
449 fn dynamic_forwards_params_rejects_blank_offset() -> Result<(), BpiError> {
450 let err = DynamicForwardsParams::new(parse_dynamic_id("1099138163191840776")?)
451 .with_offset(" ")
452 .unwrap_err();
453
454 assert!(matches!(
455 err,
456 BpiError::InvalidParameter {
457 field: "offset",
458 ..
459 }
460 ));
461 Ok(())
462 }
463
464 #[test]
465 fn dynamic_detail_read_contracts_match_endpoint_requests() -> BpiResult<()> {
466 let detail = contract("detail")?;
467 assert_eq!(detail.name, "dynamic.detail");
468 assert_eq!(detail.request.method, HttpMethod::Get);
469 assert_eq!(
470 detail.request.url.as_str(),
471 "https://api.bilibili.com/x/polymer/web-dynamic/v1/detail"
472 );
473 assert_eq!(
474 detail.request.query,
475 query_map(
476 DynamicDetailParams::new(parse_dynamic_id("1099138163191840776")?).query_pairs()
477 )
478 );
479 assert_eq!(detail.cases.len(), 3);
480
481 let reactions = contract("reactions")?;
482 assert_eq!(reactions.name, "dynamic.detail_reaction");
483 assert_eq!(
484 reactions.request.url.as_str(),
485 "https://api.bilibili.com/x/polymer/web-dynamic/v1/detail/reaction"
486 );
487 assert_eq!(
488 reactions.request.query,
489 query_map(
490 DynamicReactionsParams::new(parse_dynamic_id("1099138163191840776")?).query_pairs()
491 )
492 );
493
494 let forwards = contract("forwards")?;
495 assert_eq!(forwards.name, "dynamic.detail_forward");
496 assert_eq!(
497 forwards.request.url.as_str(),
498 "https://api.bilibili.com/x/polymer/web-dynamic/v1/detail/forward"
499 );
500 assert_eq!(
501 forwards.request.query,
502 query_map(
503 DynamicForwardsParams::new(parse_dynamic_id("1099138163191840776")?).query_pairs()
504 )
505 );
506
507 let pics = contract("pics")?;
508 assert_eq!(pics.name, "dynamic.detail_pic");
509 assert_eq!(
510 pics.request.url.as_str(),
511 "https://api.bilibili.com/x/polymer/web-dynamic/v1/detail/pic"
512 );
513 assert_eq!(
514 pics.request.query,
515 query_map(
516 DynamicPicsParams::new(parse_dynamic_id("1099138163191840776")?).query_pairs()
517 )
518 );
519
520 let forward_item = contract("forward-item")?;
521 let forward_item_id = parse_dynamic_id("1110902525317349376")?;
522 assert_eq!(forward_item.name, "dynamic.detail_forward_item");
523 assert_eq!(
524 forward_item.request.url.as_str(),
525 "https://api.bilibili.com/x/polymer/web-dynamic/v1/detail/forward/item"
526 );
527 assert_eq!(
528 forward_item.request.query,
529 query_map(DynamicForwardItemParams::new(forward_item_id).query_pairs())
530 );
531 assert_eq!(
532 forward_item.cases[0].response.error.as_deref(),
533 Some("requires_login")
534 );
535
536 let lottery_notice = contract("lottery-notice")?;
537 assert_eq!(lottery_notice.name, "dynamic.lottery_notice");
538 assert_eq!(
539 lottery_notice.request.url.as_str(),
540 "https://api.vc.bilibili.com/lottery_svr/v1/lottery_svr/lottery_notice"
541 );
542 assert_eq!(
543 lottery_notice.request.query,
544 query_map(
545 DynamicLotteryNoticeParams::new(parse_dynamic_id("969916293954142214")?)
546 .query_pairs("${csrf}")
547 )
548 );
549 assert_eq!(lottery_notice.cases.len(), 3);
550 Ok(())
551 }
552
553 #[test]
554 fn dynamic_detail_read_response_fixtures_parse_declared_models() -> BpiResult<()> {
555 for bytes in [
556 include_bytes!(
557 "../../tests/contracts/dynamic/detail/detail/responses/anonymous.success.json"
558 )
559 .as_slice(),
560 include_bytes!(
561 "../../tests/contracts/dynamic/detail/detail/responses/normal.success.json"
562 )
563 .as_slice(),
564 include_bytes!(
565 "../../tests/contracts/dynamic/detail/detail/responses/vip.success.json"
566 )
567 .as_slice(),
568 ] {
569 let payload = ApiEnvelope::<DynamicDetailData>::from_slice(bytes)?.into_payload()?;
570 assert_eq!(payload.item.id_str, "1099138163191840776");
571 }
572
573 for bytes in [
574 include_bytes!(
575 "../../tests/contracts/dynamic/detail/reactions/responses/anonymous.success.json"
576 )
577 .as_slice(),
578 include_bytes!(
579 "../../tests/contracts/dynamic/detail/reactions/responses/normal.success.json"
580 )
581 .as_slice(),
582 include_bytes!(
583 "../../tests/contracts/dynamic/detail/reactions/responses/vip.success.json"
584 )
585 .as_slice(),
586 ] {
587 let payload = ApiEnvelope::<DynamicReactionData>::from_slice(bytes)?.into_payload()?;
588 let _ = payload.total;
589 }
590
591 for bytes in [
592 include_bytes!(
593 "../../tests/contracts/dynamic/detail/forwards/responses/anonymous.success.json"
594 )
595 .as_slice(),
596 include_bytes!(
597 "../../tests/contracts/dynamic/detail/forwards/responses/normal.success.json"
598 )
599 .as_slice(),
600 include_bytes!(
601 "../../tests/contracts/dynamic/detail/forwards/responses/vip.success.json"
602 )
603 .as_slice(),
604 ] {
605 let payload = ApiEnvelope::<DynamicForwardData>::from_slice(bytes)?.into_payload()?;
606 assert_eq!(payload.items.len(), 1);
607 }
608
609 for bytes in [
610 include_bytes!(
611 "../../tests/contracts/dynamic/detail/pics/responses/anonymous.success.json"
612 )
613 .as_slice(),
614 include_bytes!(
615 "../../tests/contracts/dynamic/detail/pics/responses/normal.success.json"
616 )
617 .as_slice(),
618 include_bytes!("../../tests/contracts/dynamic/detail/pics/responses/vip.success.json")
619 .as_slice(),
620 ] {
621 let payload = ApiEnvelope::<Vec<DynamicPic>>::from_slice(bytes)?.into_payload()?;
622 assert_eq!(payload.len(), 1);
623 }
624
625 for bytes in [
626 include_bytes!(
627 "../../tests/contracts/dynamic/detail/forward-item/responses/normal.success.json"
628 )
629 .as_slice(),
630 include_bytes!(
631 "../../tests/contracts/dynamic/detail/forward-item/responses/vip.success.json"
632 )
633 .as_slice(),
634 ] {
635 let payload =
636 ApiEnvelope::<DynamicForwardInfoData>::from_slice(bytes)?.into_payload()?;
637 assert_eq!(payload.item.id_str, "1110902525317349376");
638 }
639
640 let payload = ApiEnvelope::<DynamicLotteryData>::from_slice(include_bytes!(
641 "../../tests/contracts/dynamic/lottery-notice-read/lottery-notice/responses/success.json"
642 ))?
643 .into_payload()?;
644 assert_eq!(payload.business_id, 969916293954142214);
645 assert_eq!(
646 payload
647 .lottery_result
648 .as_ref()
649 .map(|result| result.first_prize_result.len()),
650 Some(1)
651 );
652 Ok(())
653 }
654
655 #[test]
656 fn dynamic_forward_item_anonymous_fixture_records_login_error() -> BpiResult<()> {
657 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
658 "../../tests/contracts/dynamic/detail/forward-item/responses/anonymous.requires_login.json"
659 ))?
660 .ensure_success()
661 .unwrap_err();
662
663 assert_eq!(err.code(), Some(-101));
664 Ok(())
665 }
666
667 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
668 let batch = if endpoint == "lottery-notice" {
669 "lottery-notice-read"
670 } else {
671 "detail-readonly"
672 };
673 let path =
674 format!("target/bpi-probe-runs/dynamic/{batch}/{endpoint}/{profile}.response.json");
675 let bytes = std::fs::read(path).ok()?;
676 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
677 value
678 .get("response")
679 .and_then(|response| response.get("body"))
680 .cloned()
681 }
682
683 #[test]
684 fn dynamic_detail_read_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
685 for profile in ["anonymous", "normal", "vip"] {
686 if let Some(body) = local_probe_body("detail", profile) {
687 let payload = serde_json::from_value::<ApiEnvelope<DynamicDetailData>>(body)?
688 .into_payload()?;
689 assert_eq!(payload.item.id_str, "1099138163191840776");
690 }
691
692 if let Some(body) = local_probe_body("reactions", profile) {
693 let _ = serde_json::from_value::<ApiEnvelope<DynamicReactionData>>(body)?
694 .into_payload()?;
695 }
696
697 if let Some(body) = local_probe_body("forwards", profile) {
698 let payload = serde_json::from_value::<ApiEnvelope<DynamicForwardData>>(body)?
699 .into_payload()?;
700 assert!(!payload.items.is_empty());
701 }
702
703 if let Some(body) = local_probe_body("pics", profile) {
704 let payload =
705 serde_json::from_value::<ApiEnvelope<Vec<DynamicPic>>>(body)?.into_payload()?;
706 assert!(!payload.is_empty());
707 }
708 }
709
710 if let Some(body) = local_probe_body("forward-item", "anonymous") {
711 let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
712 .ensure_success()
713 .unwrap_err();
714 assert_eq!(err.code(), Some(-101));
715 }
716
717 for profile in ["normal", "vip"] {
718 if let Some(body) = local_probe_body("forward-item", profile) {
719 let payload = serde_json::from_value::<ApiEnvelope<DynamicForwardInfoData>>(body)?
720 .into_payload()?;
721 assert_eq!(payload.item.id_str, "1110902525317349376");
722 }
723 }
724
725 for profile in ["anonymous", "normal", "vip"] {
726 if let Some(body) = local_probe_body("lottery-notice", profile) {
727 let payload = serde_json::from_value::<ApiEnvelope<DynamicLotteryData>>(body)?
728 .into_payload()?;
729 assert_eq!(payload.business_id, 969916293954142214);
730 }
731 }
732 Ok(())
733 }
734}