552 lines
19 KiB
Dart
552 lines
19 KiB
Dart
// 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;
|
||
}
|
||
}
|