didvan-app/lib/services/content/content_service.dart

552 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<T> {
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<String, String> _headers({bool withAuth = true}) {
final headers = <String, String>{
'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<ContentServiceResult<ContentPage>> getContents({
int page = 1,
int limit = 20,
String? search,
String? filterStatus,
String? categoryId,
List<MapEntry<String, String>>? sortBy,
}) async {
await AuthInterceptor.ensureFreshToken();
try {
final params = <String, String>{
'page': page.toString(),
'limit': limit.toString(),
};
if (search != null && search.isNotEmpty) {
params['search'] = search;
}
if (filterStatus != null && filterStatus.isNotEmpty) {
// nestjs-paginate filter syntax: filter.<column>=value یا filter.<column>=$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<String, dynamic>) {
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<String, dynamic>) {
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<ContentServiceResult<ContentPage>> getContentsByTagNames({
required List<String> tagNames,
int page = 1,
int limit = 30,
}) async {
return getContentsByTagName(
tagName: tagNames.join(','),
page: page,
limit: limit,
);
}
/// فهرستِ content ها که با نامِ تگ مشخصی مطابقت دارند.
/// backend از `filter.tags.name=$in:<name>` پشتیبانی می‌کند.
Future<ContentServiceResult<ContentPage>> 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 = <String, String>{
'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<String, dynamic>) {
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:<userId>
Future<ContentServiceResult<ContentPage>> 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 = <String, String>{
'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<String, dynamic>) {
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<ContentServiceResult<List<ContentCategory>>> 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>()
.map((e) => ContentCategory.fromJson(Map<String, dynamic>.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<ContentServiceResult<ContentItem>> 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<String, dynamic>) {
var item = ContentItem.fromJson(body);
// resolve cover/audio/video از s3-service.
final ids = <String>{};
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<ContentServiceResult<void>> toggleLike(String id) =>
_postAction(id, 'like');
Future<ContentServiceResult<void>> toggleBookmark(String id) =>
_postAction(id, 'bookmark');
Future<ContentServiceResult<void>> markRead(String id) =>
_postAction(id, 'read');
Future<ContentServiceResult<void>> _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<ContentPage> _resolveCovers(ContentPage page) async {
final ids = <String>{};
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<T> _withRetry<T>(Future<T> 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;
}
}