484 lines
14 KiB
Dart
484 lines
14 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import '../config/api_config.dart';
|
|
import '../models/subscription_model.dart';
|
|
import 'in_app_purchase_service.dart';
|
|
|
|
/// 구독 관련 서비스
|
|
class SubscriptionService extends ChangeNotifier {
|
|
final String _baseUrl = ApiConfig.baseUrl;
|
|
final String _token;
|
|
final String _clubId;
|
|
final String _userId;
|
|
|
|
Subscription? _currentSubscription;
|
|
List<SubscriptionPlan> _availablePlans = [];
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
|
|
// 인앱 결제 서비스
|
|
final InAppPurchaseService _purchaseService;
|
|
final http.Client _httpClient;
|
|
|
|
// 기본 생성자
|
|
SubscriptionService(this._token, [this._clubId = '', this._userId = '']) :
|
|
_purchaseService = InAppPurchaseService(),
|
|
_httpClient = http.Client() {
|
|
_loadAvailablePlans();
|
|
_initializeInAppPurchase();
|
|
}
|
|
|
|
// 테스트용 생성자
|
|
factory SubscriptionService.forTest({
|
|
required http.Client client,
|
|
required InAppPurchaseService purchaseService,
|
|
required String userId,
|
|
required String token,
|
|
required String clubId,
|
|
}) {
|
|
return SubscriptionService._internal(
|
|
token,
|
|
clubId,
|
|
userId,
|
|
client,
|
|
purchaseService,
|
|
);
|
|
}
|
|
|
|
// 내부 생성자
|
|
SubscriptionService._internal(
|
|
this._token,
|
|
this._clubId,
|
|
this._userId,
|
|
this._httpClient,
|
|
this._purchaseService,
|
|
) {
|
|
_loadAvailablePlans();
|
|
_initializeInAppPurchase();
|
|
}
|
|
|
|
// 게터
|
|
Subscription? get currentSubscription => _currentSubscription;
|
|
List<SubscriptionPlan> get availablePlans => _availablePlans;
|
|
bool get isLoading => _isLoading || _purchaseService.isLoading;
|
|
String? get error => _error ?? _purchaseService.error;
|
|
bool get hasSubscription => _currentSubscription != null;
|
|
bool get isActive => _currentSubscription?.isActive ?? false;
|
|
|
|
// 인앱 결제 관련 게터
|
|
InAppPurchaseService get purchaseService => _purchaseService;
|
|
List<dynamic> get products => _purchaseService.products;
|
|
|
|
/// 현재 구독 정보 로드
|
|
Future<void> loadCurrentSubscription() async {
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final response = await _httpClient.post(
|
|
Uri.parse('$_baseUrl${ApiConfig.currentSubscription}'),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $_token',
|
|
},
|
|
body: jsonEncode({
|
|
'clubId': _clubId,
|
|
}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
_currentSubscription = Subscription.fromJson(data);
|
|
} else if (response.statusCode == 404) {
|
|
// 구독 정보가 없는 경우 (Free 플랜으로 간주)
|
|
_currentSubscription = Subscription(
|
|
id: 'free',
|
|
userId: _userId,
|
|
planType: SubscriptionPlanType.free,
|
|
status: SubscriptionStatus.active,
|
|
startDate: DateTime.now(),
|
|
endDate: DateTime.now().add(const Duration(days: 36500)), // 100년
|
|
price: 0,
|
|
currency: 'KRW',
|
|
autoRenew: false,
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
);
|
|
} else {
|
|
_error = '구독 정보를 불러오는데 실패했습니다. (${response.statusCode})';
|
|
}
|
|
} catch (e) {
|
|
_error = '구독 정보를 불러오는데 실패했습니다: $e';
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 사용 가능한 구독 플랜 목록 로드
|
|
Future<void> _loadAvailablePlans() async {
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final response = await _httpClient.get(
|
|
Uri.parse('$_baseUrl${ApiConfig.subscriptionPlans}'),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $_token',
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final List<dynamic> data = jsonDecode(response.body);
|
|
_availablePlans = data.map((plan) => SubscriptionPlan.fromJson(plan)).toList();
|
|
} else {
|
|
_error = '구독 플랜 정보를 불러오는데 실패했습니다. (${response.statusCode})';
|
|
// 기본 플랜 정보 사용
|
|
_availablePlans = SubscriptionPlan.getDefaultPlans();
|
|
}
|
|
} catch (e) {
|
|
_error = '구독 플랜 정보를 불러오는데 실패했습니다: $e';
|
|
// 기본 플랜 정보 사용
|
|
_availablePlans = SubscriptionPlan.getDefaultPlans();
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 인앱 결제 서비스 초기화
|
|
Future<void> _initializeInAppPurchase() async {
|
|
// 인앱 결제 서비스는 자체적으로 초기화됨
|
|
// 필요한 경우 추가 설정
|
|
}
|
|
|
|
/// 구독 복원
|
|
Future<bool> restoreSubscriptions() async {
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
// 인앱 구매 복원 시도
|
|
final restored = await _purchaseService.restorePurchases();
|
|
|
|
if (restored) {
|
|
// 서버에서 현재 구독 정보 가져오기
|
|
await loadCurrentSubscription();
|
|
return true;
|
|
} else {
|
|
_error = '구독을 복원할 수 없습니다.';
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
_error = '구독 복원 중 오류가 발생했습니다: $e';
|
|
return false;
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 구독 상태 검증
|
|
Future<bool> verifySubscription() async {
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final response = await _httpClient.get(
|
|
Uri.parse('$_baseUrl${ApiConfig.currentSubscription}'),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $_token',
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
_currentSubscription = Subscription.fromJson(data);
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = '구독 검증에 실패했습니다. (${response.statusCode})';
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
_error = '구독 검증에 실패했습니다: $e';
|
|
return false;
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 새 구독 생성 (인앱 결제 통합)
|
|
Future<bool> createSubscription(SubscriptionPlanType planType, bool isYearly) async {
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
// 인앱 결제 시작
|
|
final success = await _purchaseService.purchaseSubscription(planType);
|
|
|
|
if (!success) {
|
|
_error = '구독 구매 요청에 실패했습니다.';
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
|
|
// 구매 검증 결과 대기
|
|
bool verified = false;
|
|
int attempts = 0;
|
|
const maxAttempts = 10;
|
|
|
|
// 구매 검증이 완료될 때까지 최대 10회 확인
|
|
while (!verified && attempts < maxAttempts) {
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
verified = _purchaseService.isPurchaseVerified;
|
|
attempts++;
|
|
|
|
// 오류가 발생한 경우
|
|
if (_purchaseService.error != null) {
|
|
_error = _purchaseService.error;
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!verified) {
|
|
_error = '구독 검증 시간이 초과되었습니다.';
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
|
|
// 검증된 구매 정보 가져오기
|
|
final verifiedPurchase = _purchaseService.lastVerifiedPurchase;
|
|
if (verifiedPurchase == null) {
|
|
_error = '검증된 구매 정보를 찾을 수 없습니다.';
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
|
|
// 서버에 구독 생성 요청
|
|
final response = await _httpClient.post(
|
|
Uri.parse('$_baseUrl${ApiConfig.subscribeClub}'),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $_token',
|
|
},
|
|
body: jsonEncode({
|
|
'planType': planType.toString().split('.').last,
|
|
'isYearly': isYearly,
|
|
'clubId': _clubId,
|
|
'transactionId': verifiedPurchase.purchaseID,
|
|
'productId': verifiedPurchase.productID,
|
|
'verificationData': verifiedPurchase.verificationData.serverVerificationData,
|
|
}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
_currentSubscription = Subscription.fromJson(data);
|
|
|
|
// 구매 검증 상태 초기화
|
|
_purchaseService.isPurchaseVerified = false;
|
|
_purchaseService.lastVerifiedPurchase = null;
|
|
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = '구독 생성에 실패했습니다. (${response.statusCode})';
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
_error = '구독 생성 중 오류가 발생했습니다: $e';
|
|
return false;
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 구독 취소
|
|
Future<bool> cancelSubscription() async {
|
|
if (_currentSubscription == null) {
|
|
_error = '취소할 구독이 없습니다.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final response = await _httpClient.post(
|
|
Uri.parse('$_baseUrl${ApiConfig.subscriptions}/${_currentSubscription!.id}/cancel'),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $_token',
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
_currentSubscription = Subscription.fromJson(data);
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = '구독 취소에 실패했습니다. (${response.statusCode})';
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
_error = '구독 취소에 실패했습니다: $e';
|
|
return false;
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 구독 플랜 변경
|
|
Future<bool> changePlan(SubscriptionPlanType newPlanType, bool isYearly) async {
|
|
if (_currentSubscription == null) {
|
|
_error = '변경할 구독이 없습니다.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final response = await _httpClient.post(
|
|
Uri.parse('$_baseUrl${ApiConfig.changePlan}'),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $_token',
|
|
},
|
|
body: jsonEncode({
|
|
'subscriptionId': _currentSubscription!.id,
|
|
'planType': newPlanType.toString().split('.').last,
|
|
'isYearly': isYearly,
|
|
'clubId': _clubId,
|
|
}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
_currentSubscription = Subscription.fromJson(data);
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = '구독 플랜 변경에 실패했습니다. (${response.statusCode})';
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
_error = '구독 플랜 변경에 실패했습니다: $e';
|
|
return false;
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 자동 갱신 설정 변경
|
|
Future<bool> toggleAutoRenew() async {
|
|
if (_currentSubscription == null) {
|
|
_error = '변경할 구독이 없습니다.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final response = await _httpClient.post(
|
|
Uri.parse('$_baseUrl${ApiConfig.subscriptions}/autorenew'),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $_token',
|
|
},
|
|
body: jsonEncode({
|
|
'subscriptionId': _currentSubscription!.id,
|
|
'autoRenew': !_currentSubscription!.autoRenew,
|
|
'clubId': _clubId,
|
|
}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
_currentSubscription = Subscription.fromJson(data);
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = '자동 갱신 설정 변경에 실패했습니다. (${response.statusCode})';
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
_error = '자동 갱신 설정 변경에 실패했습니다: $e';
|
|
return false;
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 특정 기능이 현재 구독에서 사용 가능한지 확인
|
|
bool canUseFeature(String featureName) {
|
|
if (_currentSubscription == null || !_currentSubscription!.isActive) {
|
|
return false;
|
|
}
|
|
|
|
// 기능별 접근 권한 확인 로직
|
|
switch (featureName) {
|
|
case 'advancedStats':
|
|
return _getSubscriptionPlan(_currentSubscription!.planType).hasAdvancedStats;
|
|
case 'customization':
|
|
return _getSubscriptionPlan(_currentSubscription!.planType).hasCustomization;
|
|
case 'prioritySupport':
|
|
return _getSubscriptionPlan(_currentSubscription!.planType).hasPriority;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 현재 구독으로 추가 가능한 최대 회원 수 확인
|
|
int getMaxMembers() {
|
|
if (_currentSubscription == null || !_currentSubscription!.isActive) {
|
|
return SubscriptionPlan.getDefaultPlans().first.maxMembers; // 무료 플랜
|
|
}
|
|
return _getSubscriptionPlan(_currentSubscription!.planType).maxMembers;
|
|
}
|
|
|
|
/// 현재 구독으로 추가 가능한 최대 이벤트 수 확인
|
|
int getMaxEvents() {
|
|
if (_currentSubscription == null || !_currentSubscription!.isActive) {
|
|
return SubscriptionPlan.getDefaultPlans().first.maxEvents; // 무료 플랜
|
|
}
|
|
return _getSubscriptionPlan(_currentSubscription!.planType).maxEvents;
|
|
}
|
|
|
|
/// 구독 플랜 타입에 해당하는 플랜 정보 가져오기
|
|
SubscriptionPlan _getSubscriptionPlan(SubscriptionPlanType planType) {
|
|
return _availablePlans.firstWhere(
|
|
(plan) => plan.type == planType,
|
|
orElse: () => SubscriptionPlan.getDefaultPlans().first,
|
|
);
|
|
}
|
|
}
|