// lib/services/content/content_service.dart // // سرویس فلاتر برای صحبت با endpoint های content در API gateway جدید: // - GET /contents فهرست paginated // - GET /contents/:id جزئیات // - POST /contents/:id/like toggle like // - POST /contents/:id/bookmark toggle bookmark // - POST /contents/:id/read mark as read // // همه‌ی endpoint ها نیاز به Bearer access token دارند (و scope مناسب // در Keycloak). در صورت 403/401 خطا، پاسخ مناسب برمی‌گرداند. import 'dart:async'; import 'dart:convert'; import 'dart:developer' as dev; import 'dart:io' show SocketException; // ignore: depend_on_referenced_packages import 'package:http/http.dart' as http; import 'package:didvan/config/auth_config.dart'; import 'package:didvan/models/content/content_item.dart'; import 'package:didvan/services/auth/auth_interceptor.dart'; import 'package:didvan/services/auth/token_storage.dart'; import 'package:didvan/services/s3/s3_media_service.dart'; class ContentServiceResult { final bool isSuccess; final int? statusCode; final T? data; final String? errorMessage; const ContentServiceResult({ required this.isSuccess, this.statusCode, this.data, this.errorMessage, }); } class ContentService { ContentService._(); static final ContentService instance = ContentService._(); String get _base => AuthConfig.apiBaseUrl; static const _timeout = Duration(seconds: 30); Map _headers({bool withAuth = true}) { final headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; if (withAuth) { final t = TokenStorage.accessToken; if (t != null && t.isNotEmpty) { headers['Authorization'] = 'Bearer $t'; } } return headers; } /// فهرست paginated content ها. /// [page] از 1 شروع می‌شود. /// [limit] پیش‌فرض 20. /// [search] جستجوی متنی (روی searchable columns بک‌اند). /// [filterStatus] فیلتر بر اساس وضعیت (مثلا 'published'). /// [sortBy] لیست از tuple (column, direction) برای مرتب‌سازی. Future> getContents({ int page = 1, int limit = 20, String? search, String? filterStatus, String? categoryId, List>? sortBy, }) async { await AuthInterceptor.ensureFreshToken(); try { final params = { 'page': page.toString(), 'limit': limit.toString(), }; if (search != null && search.isNotEmpty) { params['search'] = search; } if (filterStatus != null && filterStatus.isNotEmpty) { // nestjs-paginate filter syntax: filter.=value یا filter.=$eq:value params['filter.status'] = filterStatus; } if (categoryId != null && categoryId.isNotEmpty) { // فیلتر بر اساس دسته (content-service: filter.category.id). params['filter.category.id'] = categoryId; } if (sortBy != null) { // sortBy=column:DIRECTION for (final s in sortBy) { params['sortBy'] = '${s.key}:${s.value}'; } } final uri = Uri.parse('$_base/contents').replace( queryParameters: params, ); final response = await _withRetry( () => http.get(uri, headers: _headers()).timeout(_timeout), ); if (response.statusCode == 200) { final body = json.decode(response.body); if (body is Map) { final page = await _resolveCovers(ContentPage.fromJson(body)); return ContentServiceResult( isSuccess: true, statusCode: 200, data: page, ); } } // در 401 یک بار refresh و retry if (response.statusCode == 401) { final refreshed = await AuthInterceptor.handleUnauthorized(); if (refreshed) { final retry = await http .get(uri, headers: _headers()) .timeout(_timeout); if (retry.statusCode == 200) { final body = json.decode(retry.body); if (body is Map) { final page = await _resolveCovers(ContentPage.fromJson(body)); return ContentServiceResult( isSuccess: true, statusCode: 200, data: page, ); } } return ContentServiceResult( isSuccess: false, statusCode: retry.statusCode, errorMessage: _extractError(retry.body), ); } } return ContentServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _extractError(response.body) ?? 'خطا در دریافت لیست محتوا.', ); } catch (e) { dev.log('getContents error: $e', name: 'ContentService'); return const ContentServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.', ); } } /// فهرستِ content ها که حداقل یکی از تگ‌های لیست را دارند. /// از `filter.tags.name=$in:tag1,tag2,...` استفاده می‌کند. Future> getContentsByTagNames({ required List tagNames, int page = 1, int limit = 30, }) async { return getContentsByTagName( tagName: tagNames.join(','), page: page, limit: limit, ); } /// فهرستِ content ها که با نامِ تگ مشخصی مطابقت دارند. /// backend از `filter.tags.name=$in:` پشتیبانی می‌کند. Future> getContentsByTagName({ required String tagName, int page = 1, int limit = 15, }) async { await AuthInterceptor.ensureFreshToken(); try { // اگر چند تگ با , جدا شده باشد، از $in استفاده می‌کنیم. در غیر این // صورت $eq دقیق‌تر است. final filter = tagName.contains(',') ? '\$in:$tagName' : '\$eq:$tagName'; final params = { 'page': page.toString(), 'limit': limit.toString(), 'sortBy': 'createdAt:DESC', 'filter.tags.name': filter, }; final uri = Uri.parse('$_base/contents').replace(queryParameters: params); final response = await _withRetry( () => http.get(uri, headers: _headers()).timeout(_timeout), ); if (response.statusCode == 200) { final body = json.decode(response.body); if (body is Map) { final page = await _resolveCovers(ContentPage.fromJson(body)); return ContentServiceResult( isSuccess: true, statusCode: 200, data: page, ); } } return ContentServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _extractError(response.body) ?? 'خطا در دریافت محتوای تگ.', ); } catch (e) { dev.log('getContentsByTagName error: $e', name: 'ContentService'); return const ContentServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.', ); } } /// فهرست content هایی که کاربر [userId] آن‌ها را bookmark کرده است. /// از فیلتر nestjs-paginate روی ستون bookmarks استفاده می‌کنیم: /// filter.bookmarks=$in: Future> getBookmarkedContents({ required String userId, int page = 1, int limit = 20, String? search, String? categoryId, }) async { // فیلترِ سرور روی `bookmarks` (simple-array) درست کار نمی‌کند، ولی هر آیتم // فیلدِ `isBookmarked` را برمی‌گرداند. پس محتواها را می‌گیریم و // کلاینت‌ساید آن‌هایی که bookmarked هستند را نگه می‌داریم. await AuthInterceptor.ensureFreshToken(); try { // limit بزرگ‌تر برای کاهش paging و افزایش شانس پیدا کردن bookmarkهای // کاربر در یک صفحه. final params = { 'page': page.toString(), 'limit': '100', 'filter.status': '\$in:accepted,published', 'sortBy': 'createdAt:DESC', }; if (search != null && search.isNotEmpty) { params['search'] = search; } if (categoryId != null && categoryId.isNotEmpty) { params['filter.category.id'] = categoryId; } final uri = Uri.parse('$_base/contents').replace(queryParameters: params); final response = await _withRetry( () => http.get(uri, headers: _headers()).timeout(_timeout), ); if (response.statusCode == 200) { final body = json.decode(response.body); if (body is Map) { final fullPage = ContentPage.fromJson(body); // فقط آیتم‌هایی که کاربرِ فعلی bookmark کرده‌است را نگه می‌داریم. final bookmarked = fullPage.data.where((c) => c.isBookmarked).toList(); final filteredPage = ContentPage( data: bookmarked, currentPage: fullPage.currentPage, totalPages: fullPage.totalPages, totalItems: bookmarked.length, itemsPerPage: bookmarked.length, ); final resolved = await _resolveCovers(filteredPage); return ContentServiceResult( isSuccess: true, statusCode: 200, data: resolved, ); } } return ContentServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _extractError(response.body) ?? 'خطا در دریافت نشان‌شده‌ها.', ); } catch (e) { dev.log('getBookmarkedContents error: $e', name: 'ContentService'); return const ContentServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.', ); } } /// لیست دسته‌بندی‌های محتوا (`GET /categories`). /// برای فهمیدن اسم/شناسه‌ی دسته‌ها مثل «دنیای فولاد»، «پویش افق»، «رادار…». /// نیاز به scope `get-category` دارد (content-user-role آن را دارد). Future>> getCategories({ int limit = 100, }) async { await AuthInterceptor.ensureFreshToken(); try { final uri = Uri.parse('$_base/categories').replace(queryParameters: { 'page': '1', 'limit': limit.toString(), }); final response = await _withRetry( () => http.get(uri, headers: _headers()).timeout(_timeout), ); dev.log('GET /categories -> status=${response.statusCode}', name: 'ContentService'); if (response.statusCode == 200) { final body = json.decode(response.body); final List items = body is Map && body['data'] is List ? body['data'] as List : (body is List ? body : const []); final cats = items .whereType() .map((e) => ContentCategory.fromJson(Map.from(e))) .toList(); return ContentServiceResult( isSuccess: true, statusCode: 200, data: cats, ); } return ContentServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _extractError(response.body) ?? 'خطا در دریافت دسته‌بندی‌ها.', ); } catch (e) { dev.log('getCategories error: $e', name: 'ContentService'); return const ContentServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.', ); } } /// جزئیات یک content. در صورت ارسال userId در سمت سرور، خوانده‌شدن ثبت می‌شود. Future> getContent(String id) async { await AuthInterceptor.ensureFreshToken(); try { final response = await _withRetry( () => http .get(Uri.parse('$_base/contents/$id'), headers: _headers()) .timeout(_timeout), ); if (response.statusCode == 200) { final body = json.decode(response.body); if (body is Map) { var item = ContentItem.fromJson(body); // resolve cover/audio/video از s3-service. final ids = {}; if ((item.coverImage == null || item.coverImage!.isEmpty) && item.coverImageId != null && item.coverImageId!.isNotEmpty) { ids.add(item.coverImageId!); } if ((item.audioUrl == null || item.audioUrl!.isEmpty) && item.audioId != null && item.audioId!.isNotEmpty) { ids.add(item.audioId!); } if ((item.videoUrl == null || item.videoUrl!.isEmpty) && item.videoId != null && item.videoId!.isNotEmpty) { ids.add(item.videoId!); } if (ids.isNotEmpty) { final urls = await S3MediaService.instance.resolveBulk(ids.toList()); final dur = S3MediaService.instance.durationFor(item.audioId) ?? S3MediaService.instance.durationFor(item.videoId); item = item.copyWith( coverImage: urls[item.coverImageId] ?? item.coverImage, audioUrl: urls[item.audioId] ?? item.audioUrl, videoUrl: urls[item.videoId] ?? item.videoUrl, mediaDuration: dur ?? item.mediaDuration, ); } return ContentServiceResult( isSuccess: true, statusCode: 200, data: item, ); } } return ContentServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _extractError(response.body) ?? 'خطا در دریافت محتوا.', ); } catch (e) { dev.log('getContent error: $e', name: 'ContentService'); return const ContentServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.', ); } } Future> toggleLike(String id) => _postAction(id, 'like'); Future> toggleBookmark(String id) => _postAction(id, 'bookmark'); Future> markRead(String id) => _postAction(id, 'read'); Future> _postAction( String id, String action) async { await AuthInterceptor.ensureFreshToken(); try { final response = await _withRetry( () => http .post( Uri.parse('$_base/contents/$id/$action'), headers: _headers(), ) .timeout(_timeout), ); if (response.statusCode == 200 || response.statusCode == 201) { return const ContentServiceResult(isSuccess: true, statusCode: 200); } return ContentServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _extractError(response.body), ); } catch (e) { dev.log('$action error: $e', name: 'ContentService'); return const ContentServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.', ); } } // ----------------- Helpers ----------------- /// برای هر آیتمی که شناسه‌ی مدیا دارد (cover/audio/video) ولی URL ندارد، /// URL signed را از s3-service می‌گیرد. اگر resolve با خطا مواجه شود، /// آن آیتم بدون URL می‌ماند (نه crash). Future _resolveCovers(ContentPage page) async { final ids = {}; for (final c in page.data) { if ((c.coverImage == null || c.coverImage!.isEmpty) && c.coverImageId != null && c.coverImageId!.isNotEmpty) { ids.add(c.coverImageId!); } if ((c.audioUrl == null || c.audioUrl!.isEmpty) && c.audioId != null && c.audioId!.isNotEmpty) { ids.add(c.audioId!); } if ((c.videoUrl == null || c.videoUrl!.isEmpty) && c.videoId != null && c.videoId!.isNotEmpty) { ids.add(c.videoId!); } } if (ids.isEmpty) return page; final urls = await S3MediaService.instance.resolveBulk(ids.toList()); if (urls.isEmpty) return page; final newItems = page.data.map((c) { String? newCover; String? newAudio; String? newVideo; if ((c.coverImage == null || c.coverImage!.isEmpty) && c.coverImageId != null && urls[c.coverImageId] != null) { newCover = urls[c.coverImageId]; } if ((c.audioUrl == null || c.audioUrl!.isEmpty) && c.audioId != null && urls[c.audioId] != null) { newAudio = urls[c.audioId]; } if ((c.videoUrl == null || c.videoUrl!.isEmpty) && c.videoId != null && urls[c.videoId] != null) { newVideo = urls[c.videoId]; } // duration از کش S3MediaService (که در همان fetch URL پر شده) برداشت می‌شود. int? newDuration; if (newAudio != null) { newDuration = S3MediaService.instance.durationFor(c.audioId); } else if (newVideo != null) { newDuration = S3MediaService.instance.durationFor(c.videoId); } if (newCover == null && newAudio == null && newVideo == null && newDuration == null) { return c; } return c.copyWith( coverImage: newCover, audioUrl: newAudio, videoUrl: newVideo, mediaDuration: newDuration, ); }).toList(); return ContentPage( data: newItems, currentPage: page.currentPage, totalPages: page.totalPages, totalItems: page.totalItems, itemsPerPage: page.itemsPerPage, ); } Future _withRetry(Future Function() body) async { for (var attempt = 0; attempt < 3; attempt++) { try { return await body(); } on SocketException { if (attempt == 2) rethrow; await Future.delayed(Duration(milliseconds: 250 * (attempt + 1))); } on http.ClientException { if (attempt == 2) rethrow; await Future.delayed(Duration(milliseconds: 250 * (attempt + 1))); } on TimeoutException { if (attempt == 2) rethrow; } } throw StateError('retry loop exited without return'); } String? _extractError(String body) { if (body.isEmpty) return null; try { final data = json.decode(body); if (data is Map) { final msg = data['message'] ?? data['error'] ?? data['msg']; if (msg is String && msg.isNotEmpty) return msg; if (msg is List && msg.isNotEmpty) return msg.first.toString(); } } catch (_) {} return null; } }