iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 15

Day 15 - Cloud Firestore:即時資料庫設計與優化

  • 分享至 

  • xImage
  •  

在 Day 14 完成 Firebase Authentication 後,今天我們要深入探討 Cloud Firestore,學習如何設計和優化即時資料庫。在 CrewUp 專案中,我們需要處理活動資料、聊天訊息、通知等多種即時資料,Firestore 提供了完整的解決方案。

從實際開發經驗來看,Firestore 的即時同步功能讓我們可以輕鬆建立動態的社群應用,使用者可以即時看到活動更新、新訊息通知等。結合我們在 Day 1-13 建立的 Clean Architecture、狀態管理等基礎設施,讓我們可以專注在核心功能開發上。

🎯 Firestore 策略:CrewUp 的實際需求分析

為什麼選擇 Firestore?

在 CrewUp 專案中,我們選擇 Firestore 的考量主要有這幾個面向:

🚀 即時同步很實用

  • 活動參與狀況可以即時更新給所有成員
  • 聊天功能可以即時顯示新訊息
  • 通知可以即時推送到使用者

📱 離線支援很強大

  • 使用者離線時仍可瀏覽已載入的資料
  • 網路恢復時自動同步所有變更
  • 提供良好的使用者體驗

🔧 查詢功能很靈活

  • 支援複雜的查詢條件
  • 自動建立索引提升效能
  • 分頁查詢支援大量資料

NoSQL 資料模型的權衡考量

在設計 Firestore 資料結構時,我們面臨一個重要的設計決策:正規化 vs 反正規化

🎯 我們的選擇:反正規化策略

在 CrewUp 專案中,我們選擇了反正規化的設計方式:

// 活動文件結構(反正規化)
{
  "id": "activity_123",
  "title": "週末爬山活動",
  "description": "一起來爬山吧!",
  "founderId": "user_456",
  "founderName": "張小明",        // 嵌入創建者姓名
  "founderPhotoUrl": "https://...", // 嵌入創建者頭像
  "createdAt": "2024-01-15T10:00:00Z",
  "participants": [
    {
      "userId": "user_456",
      "name": "張小明",           // 嵌入參與者姓名
      "photoUrl": "https://..."   // 嵌入參與者頭像
    }
  ]
}

📊 權衡分析:

✅ 優點(讀取效能優化)

  • 讀取活動列表時不需要額外查詢 user_profiles 集合
  • 減少網路請求和資料庫讀取次數
  • 提升 UI 載入速度和使用者體驗

❌ 缺點(維護複雜度)

  • 使用者更新姓名時,需要同步更新所有相關的活動文件
  • 資料一致性維護成本較高
  • 可能出現資料不同步的情況

💡 解決方案:Cloud Functions 自動同步

為了處理反正規化帶來的維護問題,我們可以使用 Cloud Functions 來實現自動同步:

// Cloud Function:使用者資料變更時自動同步
exports.syncUserData = functions.firestore
  .document('user_profiles/{userId}')
  .onUpdate(async (change, context) => {
    const newData = change.after.data();
    const oldData = change.before.data();
    
    // 如果姓名或頭像有變更,同步更新相關活動
    if (newData.displayName !== oldData.displayName || 
        newData.photoUrl !== oldData.photoUrl) {
      
      const batch = admin.firestore().batch();
      
      // 更新使用者創建的活動
      const activitiesSnapshot = await admin.firestore()
        .collection('activities')
        .where('founderId', '==', context.params.userId)
        .get();
        
      activitiesSnapshot.docs.forEach(doc => {
        batch.update(doc.ref, {
          founderName: newData.displayName,
          founderPhotoUrl: newData.photoUrl
        });
      });
      
      await batch.commit();
    }
  });

📋 設計建議:

  1. 對於讀取頻繁的資料:我們建議優先考慮反正規化
  2. 對於寫入頻繁的資料:我們建議優先考慮正規化
  3. 使用 Cloud Functions:自動處理資料同步
  4. 定期資料清理:確保資料一致性

📋 實作步驟:Firestore 完整整合

1. 依賴套件設定

在 CrewUp 專案中,我們使用了以下核心依賴:

# pubspec.yaml
dependencies:
  cloud_firestore: ^6.0.1          # Firestore 資料庫
  firebase_core: ^4.1.0            # Firebase 核心
  connectivity_plus: ^6.1.0        # 網路狀態檢測
  flutter_riverpod: ^2.6.1         # 狀態管理

2. 資料庫架構設計

在 CrewUp 專案中,我們遵循 Clean Architecture 原則,將 Firestore 操作分層實作:

🔧 Data Layer - ActivityFirebaseDataSource

// lib/features/activity/data/datasources/activity_firebase_datasource.dart

// (imports omitted)

class ActivityFirebaseDataSource implements ActivityDataSource {
  final FirebaseFirestore _firestore;
  final UserInfoService _userInfoService;
  static const String _collectionName = 'activities';

  ActivityFirebaseDataSource({
    FirebaseFirestore? firestore,
    required UserInfoService userInfoService,
  }) : _firestore = firestore ?? FirebaseFirestore.instance,
       _userInfoService = userInfoService;

  @override
  Future<List<Activity>> getAllActivities() async {
    // ⚠️ 注意:此方法僅用於開發測試,生產環境應使用分頁查詢
    // 一次性讀取整個集合會隨著資料增長導致效能和費用問題
    try {
      final querySnapshot = await _firestore
          .collection(_collectionName)
          .orderBy('createdAt', descending: true)
          .limit(50) // 限制讀取數量,避免過度消耗
          .get();

      final activities = querySnapshot.docs
          .map((doc) => _documentSnapshotToActivity(doc))
          .where((activity) => activity != null)
          .cast<Activity>()
          .toList();

      return activities;
    } on FirebaseException catch (e) {
      throw NetworkException('Failed to fetch activities: ${e.message}');
    }
  }

  /// 分頁查詢活動(推薦用於生產環境)
  /// 
  /// ✅ 使用分頁查詢的優點:
  /// - 控制每次讀取的資料量,避免效能問題
  /// - 減少網路傳輸和費用
  /// - 提供更好的使用者體驗(漸進式載入)
  Future<List<Activity>> getActivitiesWithPagination({
    int limit = 20,
    DocumentSnapshot? lastDocument,
  }) async {
    try {
      Query<Map<String, dynamic>> query = _firestore
          .collection(_collectionName)
          .orderBy('createdAt', descending: true)
          .limit(limit);

      if (lastDocument != null) {
        query = query.startAfterDocument(lastDocument);
      }

      final querySnapshot = await query.get();
      final activities = querySnapshot.docs
          .map((doc) => _documentToActivity(doc))
          .where((activity) => activity != null)
          .cast<Activity>()
          .toList();

      return activities;
    } on FirebaseException catch (e) {
      throw NetworkException('Failed to fetch activities with pagination: ${e.message}');
    }
  }
}

3. 即時監聽器實作與生命週期管理

📡 即時監聽活動變化

// lib/features/activity/data/datasources/activity_firebase_datasource.dart

// (imports omitted)

/// 即時監聽所有活動變化
/// 
/// ⚠️ 重要:即時監聽器會產生費用,每次文件變更都會觸發讀取
/// ✅ 使用 Riverpod 的 StreamProvider 可以自動管理監聽器生命週期
Stream<List<Activity>> watchAllActivities() {
  return _firestore
      .collection(_collectionName)
      .orderBy('createdAt', descending: true)
      .snapshots()
      .map((snapshot) {
    final activities = snapshot.docs
        .map((doc) => _documentSnapshotToActivity(doc))
        .where((activity) => activity != null)
        .cast<Activity>()
        .toList();

    developer.log('📡 Real-time update - ${activities.length} activities');
    return activities;
  }).handleError((error) {
    developer.log('❌ Real-time listener failed: $error');
    throw NetworkException('Real-time listener failed: $error');
  });
}

/// 即時監聽特定活動變化
Stream<Activity?> watchActivityById(String id) {
  return _firestore
      .collection(_collectionName)
      .doc(id)
      .snapshots()
      .map((snapshot) {
    if (!snapshot.exists) {
      developer.log('⚠️ Activity not found in real-time: $id');
      return null;
    }

    final activity = _documentSnapshotToActivity(snapshot);
    developer.log('📡 Real-time update for activity: ${activity?.title}');
    return activity;
  }).handleError((error) {
    developer.log('❌ Real-time listener failed: $error');
    throw NetworkException('Real-time listener failed: $error');
  });
}

🔄 Riverpod 自動生命週期管理

使用 Riverpod 的 StreamProvider 可以非常優雅地自動管理監聽器生命週期:

// lib/features/activity/presentation/providers/activity_provider.dart

// (imports omitted)

@riverpod
Stream<List<Activity>> activitiesStream(ActivitiesStreamRef ref) {
  final dataSource = ref.read(activityFirebaseDataSourceProvider);
  return dataSource.watchAllActivities();
}

// 在 UI 中使用
class ActivityListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final activitiesAsync = ref.watch(activitiesStreamProvider);
    
    return activitiesAsync.when(
      data: (activities) => ListView.builder(
        itemCount: activities.length,
        itemBuilder: (context, index) => ActivityCard(activity: activities[index]),
      ),
      loading: () => const CircularProgressIndicator(),
      error: (error, stack) => Text('錯誤: $error'),
    );
  }
}

✅ 生命週期管理的好處:

✅ 自動資源管理:當沒有任何 UI 元件監聽時,Riverpod 會自動銷毀 StreamProvider
✅ 避免記憶體洩漏:防止監聽器在背景持續運行
✅ 節省費用:避免不必要的 Firestore 讀取費用
✅ 提升效能:減少背景資源消耗

4. 多國語系錯誤處理:國際化的錯誤訊息

在 CrewUp 專案中,我們使用 LocalizationService 來提供多國語系的錯誤訊息:

// lib/features/activity/data/datasources/activity_firebase_datasource.dart

import 'package:crew_up/app/core/services/localization_service.dart';

@override
Future<void> joinActivity(String activityId, String userId) async {
  try {
    await _firestore.runTransaction((transaction) async {
      final activityDoc = await transaction.get(activityRef);

      if (!activityDoc.exists) {
        throw AppException.notFound(LocalizationService.instance.current.activityNotFound);
      }

      // 檢查用戶是否已經參與
      final isAlreadyParticipant = participants.any((p) => p['id'] == userId);
      if (isAlreadyParticipant) {
        throw AppException.validation(LocalizationService.instance.current.userAlreadyJoined);
      }

      // 檢查活動是否已滿
      if (participants.length >= maxParticipants) {
        throw AppException.validation(LocalizationService.instance.current.activityFull);
      }
    });
  } on FirebaseException catch (e) {
    // 根據 Firebase 錯誤碼提供更具體的錯誤訊息
    switch (e.code) {
      case 'permission-denied':
        throw AppException.authentication(LocalizationService.instance.current.activityPermissionDenied);
      case 'not-found':
        throw AppException.notFound(LocalizationService.instance.current.activityNotFound);
      case 'failed-precondition':
        throw AppException.validation(LocalizationService.instance.current.activityFull);
      case 'unavailable':
        throw NetworkException(LocalizationService.instance.current.serviceUnavailable);
      default:
        throw NetworkException(LocalizationService.instance.current.activityJoinFailed);
    }
  }
}

🌍 多國語系錯誤訊息的優點:

✅ 使用者體驗優化:根據使用者語言顯示適當的錯誤訊息
✅ 維護性提升:集中管理所有錯誤訊息,便於更新
✅ 一致性保證:確保所有錯誤訊息使用相同的語系
✅ 擴展性良好:容易添加新的語言支援

📄 多國語系檔案範例:

// lib/l10n/app_en.arb
{
  "activityNotFound": "Activity not found",
  "activityFull": "Activity is full, cannot join",
  "userAlreadyJoined": "You have already joined this activity",
  "activityJoinFailed": "Failed to join activity",
  "activityLeaveFailed": "Failed to leave activity",
  "serviceUnavailable": "Service temporarily unavailable, please try again later"
}

// lib/l10n/app_zh.arb
{
  "activityNotFound": "找不到活動",
  "activityFull": "活動已滿,無法加入",
  "userAlreadyJoined": "您已經加入此活動",
  "activityJoinFailed": "加入活動失敗",
  "activityLeaveFailed": "退出活動失敗",
  "serviceUnavailable": "服務暫時不可用,請稍後再試"
}

5. UI 實作:即時資料顯示

在 CrewUp 專案中,我們使用 Riverpod 的 StreamProvider:

// lib/features/activity/presentation/screens/activity_list_screen.dart

// (imports omitted)

class ActivityListScreen extends ConsumerWidget {
  const ActivityListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final activitiesAsync = ref.watch(activitiesStreamProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('活動列表')),
      body: activitiesAsync.when(
        data: (activities) {
          if (activities.isEmpty) {
            return const Center(child: Text('目前沒有活動'));
          }
          
          return ListView.builder(
            itemCount: activities.length,
            itemBuilder: (context, index) {
              final activity = activities[index];
              return ActivityCard(activity: activity);
            },
          );
        },
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stack) => Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('載入失敗: $error'),
              ElevatedButton(
                onPressed: () => ref.invalidate(activitiesStreamProvider),
                child: const Text('重新載入'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

🔐 安全規則設計:Firestore 安全策略

Firestore 安全規則實作

在 CrewUp 專案中,我們建立的規則:

// firestore.rules
rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    
    // =================================
    // 輔助函式 (Helper Functions)
    // =================================
    
    // 檢查使用者是否已登入
    function isSignedIn() {
      return request.auth != null;
    }
    
    // 檢查使用者是否為資源的擁有者
    function isOwner(userId) {
      return isSignedIn() && request.auth.uid == userId;
    }
    
    // 檢查使用者是否為聊天室成員
    function isChatParticipant(chatId) {
      return isSignedIn() && request.auth.uid in get(/databases/$(database)/documents/chats/$(chatId)).data.participants;
    }
    
    // =================================
    // 集合規則 (Collection Rules)
    // =================================
    
    match /user_profiles/{userId} {
      // 擁有者才能讀取自己的資料
      allow read: if isOwner(userId);
      
      // 任何人都能建立自己的帳號,但需通過資料驗證
      allow create: if isOwner(userId)
        && request.resource.data.email == request.auth.token.email // 確保 email 與 token 一致
        && request.resource.data.displayName is string
        && request.resource.data.displayName.size() > 0
        && request.resource.data.displayName.size() <= 50
        && request.resource.data.createdAt == request.time // 強制使用伺服器時間
        && request.resource.data.updatedAt == request.time;
      
      // 擁有者才能更新自己的資料,且部分資料不可變
      allow update: if isOwner(userId)
        && request.resource.data.email == resource.data.email // email 不可變
        && request.resource.data.createdAt == resource.data.createdAt // createdAt 不可變
        && request.resource.data.displayName is string
        && request.resource.data.displayName.size() > 0
        && request.resource.data.displayName.size() <= 50
        && request.resource.data.updatedAt == request.time; // updatedAt 必須是伺服器時間
    }
    
    match /activities/{activityId} {
      // 登入使用者才能讀取活動(收緊公開讀取權限)
      allow read: if isSignedIn();
      
      // 登入使用者才能建立活動,且 founderId 必須是自己
      allow create: if isSignedIn()
        && request.resource.data.founderId == request.auth.uid
        && request.resource.data.title is string
        && request.resource.data.title.size() > 0
        && request.resource.data.title.size() <= 100
        && request.resource.data.createdAt == request.time
        && request.resource.data.updatedAt == request.time;
      
      // 只有創建者可以更新或刪除
      allow update: if isSignedIn()
        && resource.data.founderId == request.auth.uid
        && request.resource.data.founderId == resource.data.founderId // founderId 不可變
        && request.resource.data.createdAt == resource.data.createdAt // createdAt 不可變
        && request.resource.data.updatedAt == request.time;
      
      allow delete: if isSignedIn() && resource.data.founderId == request.auth.uid;
    }
    
    match /chats/{chatId} {
      // 只有聊天室成員可以讀、寫、刪
      allow read, update, delete: if isChatParticipant(chatId);
      
      // 建立聊天室時,建立者必須是自己且在參與者列表中
      allow create: if isSignedIn()
        && request.resource.data.createdBy == request.auth.uid
        && request.auth.uid in request.resource.data.participants
        && request.resource.data.participants is list
        && request.resource.data.participants.size() >= 2
        && request.resource.data.createdAt == request.time;
    }
  }
}

安全規則的設計原則

🛡️ 最小權限原則

  • 使用者只能存取自己相關的資料
  • 登入使用者才能讀取公開資料(防止爬蟲濫用)
  • 敏感資料需要明確的權限控制

🔒 資料驗證與不可變性

  • 確保資料結構符合預期
  • 驗證使用者身份和權限
  • 防止惡意資料寫入
  • 保護關鍵欄位不被修改(如 userId、email、createdAt)

⚙️ 函式化設計

  • 使用輔助函式簡化重複邏輯
  • 提高規則的可讀性和可維護性
  • 便於未來擴展(如管理員角色)

安全規則的實踐

📋 分離 Create 與 Update 驗證

  • Create 操作:驗證所有必要欄位,確保資料完整性
  • Update 操作:保護不可變欄位,只允許修改可變欄位
  • 時間戳管理:createdAt 只在建立時設定,updatedAt 在每次更新時刷新

🔐 收緊公開資料存取權限

  • allow read: if true 改為 allow read: if isSignedIn()
  • 防止匿名爬蟲大量抓取資料
  • 保留未來控制權(如付費會員功能)

📧 Email 與 Token 一致性驗證

  • 確保使用者資料中的 email 與 Firebase Auth token 一致
  • 防止使用者偽造 email 欄位
  • 提高資料的可靠性和安全性

📱 離線支援:離線優先的應用設計

網路狀態監聽服務

在 CrewUp 專案中,我們建立了專門的網路狀態監聽服務:

// lib/common/services/network_status_service.dart

// (imports omitted)

/// 網路狀態監聽服務
///
/// 提供網路狀態檢測和監聽功能,讓 App 的 UI 能夠感知網路狀態變化
/// 注意:Firestore 的離線快取和同步完全由其 SDK 自動處理,此服務僅用於 UI 狀態提示
class NetworkStatusService {
  final FirebaseFirestore _firestore;
  final Connectivity _connectivity;

  NetworkStatusService({
    FirebaseFirestore? firestore,
    Connectivity? connectivity,
  }) : _firestore = firestore ?? FirebaseFirestore.instance,
       _connectivity = connectivity ?? Connectivity();

  /// 檢查網路狀態
  Future<bool> isOnline() async {
    try {
      final connectivityResult = await _connectivity.checkConnectivity();
      final isOnline = !connectivityResult.contains(ConnectivityResult.none);
      
      developer.log('🌐 網路狀態: ${isOnline ? "線上" : "離線"}');
      return isOnline;
    } on Exception catch (e) {
      developer.log('❌ 檢查網路狀態失敗: $e');
      return false;
    }
  }

  /// 監聽網路狀態變化
  Stream<bool> watchNetworkStatus() => _connectivity.onConnectivityChanged.map((result) {
    final isOnline = !result.contains(ConnectivityResult.none);
    developer.log('🌐 網路狀態變化: ${isOnline ? "線上" : "離線"}');
    return isOnline;
  });
}

離線支援的使用者體驗

📱 Firestore 自動離線處理

  • Firestore SDK 會自動快取查詢結果
  • 離線時使用快取資料,提供無縫體驗
  • 網路恢復時自動同步所有變更

🔄 UI 狀態提示

  • 使用 NetworkStatusService 監聽網路狀態
  • 在 UI 中顯示「離線模式」提示
  • 讓使用者了解當前的連線狀態

⚡ 離線時的功能

  • 瀏覽已載入的活動列表
  • 查看聊天歷史記錄
  • 離線時的操作會排隊等待同步

🚀 查詢優化:高效查詢的設計技巧

Firestore 索引設定

在 CrewUp 專案中,我們建立了完整的索引設定:

// firestore.indexes.json
{
  "indexes": [
    {
      "collectionGroup": "activities",
      "queryScope": "COLLECTION",
      "fields": [
        {
          "fieldPath": "category",
          "order": "ASCENDING"
        },
        {
          "fieldPath": "createdAt",
          "order": "DESCENDING"
        }
      ]
    },
    {
      "collectionGroup": "activities",
      "queryScope": "COLLECTION",
      "fields": [
        {
          "fieldPath": "status",
          "order": "ASCENDING"
        },
        {
          "fieldPath": "createdAt",
          "order": "DESCENDING"
        }
      ]
    },
    {
      "collectionGroup": "messages",
      "queryScope": "COLLECTION_GROUP",
      "fields": [
        {
          "fieldPath": "chatId",
          "order": "ASCENDING"
        },
        {
          "fieldPath": "timestamp",
          "order": "DESCENDING"
        }
      ]
    }
  ]
}

查詢優化技巧

📄 分頁查詢的重要性

  • 推薦:使用 getActivitiesWithPagination() 進行分頁查詢
  • 避免:使用 getAllActivities() 一次性讀取整個集合
  • ⚠️ 原因:一次性讀取會隨著資料增長導致嚴重的效能和費用問題

🔍 分頁查詢實作

  • 使用 limit() 限制每次查詢的資料量
  • 使用 startAfterDocument() 實作游標分頁
  • 避免使用 offset() 在大資料集上的效能問題

🔗 複合查詢

  • 為常用查詢組合建立複合索引
  • 避免在單一查詢中使用過多條件
  • 優先使用等值查詢和範圍查詢

💾 快取策略

  • Firestore 自動快取查詢結果
  • 離線時使用快取資料
  • 網路恢復時自動同步

🧪 測試策略:Firestore 模組的完整測試

測試策略

在 CrewUp 專案中,我們為 Firestore 模組實作了完整的測試覆蓋:

單元測試:測試 Firestore 操作的核心邏輯
整合測試:測試完整的資料流程
即時監聽測試:測試 Stream 的即時更新

測試涵蓋了正常查詢、錯誤處理、離線支援、即時同步等各種場景,確保 Firestore 系統的穩定性和可靠性。

🚀 結語:從技術實作到實務思維的轉變

核心觀念總結

透過今天的實戰探索,我們學到了以下核心觀念:

✅ Firestore 即時同步是強大的功能

  • 使用 snapshots() 建立即時監聽器
  • 自動處理資料變更和 UI 更新
  • 提供流暢的使用者體驗

✅ 安全規則設計很重要

  • 最小權限原則保護使用者資料
  • 資料驗證確保資料完整性
  • 分層權限控制不同類型的資料

✅ 離線支援提升使用者體驗

  • Firestore 自動處理離線快取
  • 網路恢復時自動同步
  • 提供無縫的離線使用體驗

✅ 查詢優化影響效能

  • 建立適當的複合索引
  • 使用分頁查詢處理大量資料
  • 快取策略減少網路請求

實際開發中的好處

🚀 開發效率大幅提升

  • Firestore 自動處理複雜的同步邏輯
  • 即時監聽器讓 UI 更新變得簡單
  • 離線支援減少開發工作量

📱 使用者體驗優化

  • 即時同步提供流暢的互動體驗
  • 離線支援讓使用者隨時可以使用
  • 自動同步確保資料一致性

🔧 系統穩定性保障

  • Firestore 的成熟機制確保系統穩定
  • 完整的錯誤處理讓問題更容易追蹤
  • 自動重試機制處理網路問題

下一步

明天,我們將深入探討 Firebase Storage,學習檔案上傳與管理的最佳實踐,並結合本專案的實際需求進行應用。

期待與您在 Day 16 相見!


📋 相關資源

📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 15 - Cloud Firestore:即時資料庫設計與優化
  • 文章日期: 2025-09-29
  • 技術棧: Cloud Firestore, Firebase Auth, Riverpod, Clean Architecture

上一篇
Day 14 - Firebase Authentication:從 Google 登入到完整認證系統
下一篇
Day 16 - Firebase Storage:檔案上傳與管理的最佳實踐
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言