import { ISubscriptionPlanData } from './type'; import { ISubscriptionTierData } from '@jolibox/types'; import { Deferred, isUndefinedOrNull, sleep } from '@jolibox/common'; import { StandardResponse } from '@jolibox/types'; import { applyNative, onNative } from '@jolibox/native-bridge'; import { innerFetch as fetch } from '@/native/network'; import { RequestCacheService, RequestAdapter } from '@jolibox/common'; import { executeAndCache, executeWithPersistentCache } from '../payment/utils/cache-with-storage'; import { subscriptionHelper } from './subscription-helper'; import { invokeNative } from '@jolibox/native-bridge'; import { createSubscriptionInternalError } from './registers/base'; import { SubscriptionErrorCodeMap } from './registers/type'; import { ResponseType } from '@jolibox/types'; interface IUserSubData { subPlanId: string; tier: string; domain: string; status: 'ACTIVE' | 'CANCELED' | 'IN_GRACE' | 'EXPIRED'; validUntil: number; planType: string; haveNextPayment: boolean; } // Request/Response interfaces for RequestCacheService type SubscriptionRequest = Record; type SubscriptionResponse = ISubscriptionTierData[]; interface SubscriptionCacheData { subscriptionTiers: ISubscriptionTierData[]; } // Utility function for merging native pricing data with subscription tiers function mergeSubscriptionPriceData( responseData: ISubscriptionTierData[], nativePriceData: { [appStoreProductId: string]: ISubscriptionPlanData } ) { Object.keys(nativePriceData).forEach((productId) => { const tier = responseData.find((tier) => tier.appStoreProductId === productId); if (tier) { tier.amountStr = nativePriceData[productId].pricing.formattedPrice; tier.originalAmountStr = nativePriceData[productId].pricing.originalPrice; tier.discountPercentage = nativePriceData[productId].pricing.discountPercentage?.toString() ?? ''; tier.trialDay = nativePriceData[productId].trial?.duration ?? 0; } }); // Filter out tiers without pricing information responseData = responseData.filter((tier) => !isUndefinedOrNull(tier.amountStr)) as ISubscriptionTierData[]; } // Subscription Request Adapter for RequestCacheService class SubscriptionRequestAdapter implements RequestAdapter { // Native price cache for subscription tiers private static nativePriceCache = new Map(); private static readonly NATIVE_PRICE_CACHE_DURATION = 1 * 24 * 60 * 60 * 1000; // 1 days private static nativePriceCacheTimestamp = new Map(); static getNativePriceCache(appStoreProductIds: string[]): { [appStoreProductId: string]: ISubscriptionPlanData; } { const cacheKey = appStoreProductIds.sort().join(','); return this.nativePriceCache.get(cacheKey) ?? {}; } async execute(endpoint: string, _options?: SubscriptionRequest): Promise { // 获取服务端订阅档位数据 const { response } = await fetch>(endpoint, { method: 'GET', appendHostCookie: true, responseType: 'json' }); if (!response.data?.data) { throw new Error('get subscription tiers failed'); } const serverData = response.data.data; const appStoreProductIds = serverData ?.filter((tier: ISubscriptionTierData) => typeof tier.appStoreProductId === 'string') .map((tier) => tier.appStoreProductId as string) ?? []; // 如果有需要查询原生价格的产品,则获取价格信息 if (appStoreProductIds.length > 0) { const nativePriceData = await this.getNativePriceData(appStoreProductIds); if (nativePriceData) { mergeSubscriptionPriceData(serverData, nativePriceData); } } if (serverData.length === 0) { throw new Error('subscription tiers is empty'); } return serverData; } private async requestNativeSubscriptionPlans( appStoreProductIds: string[], cacheKey: string ): Promise<{ [appStoreProductId: string]: ISubscriptionPlanData }> { try { const { data } = await applyNative('requestSubscriptionPlansAsync', { appStoreProductIds, type: 'game' }); if (data) { SubscriptionRequestAdapter.nativePriceCache.set(cacheKey, data); SubscriptionRequestAdapter.nativePriceCacheTimestamp.set(cacheKey, Date.now()); } return data; } catch (error) { console.warn('[SubscriptionService] Failed to fetch native subscription pricing:', error); throw error; } } private async getNativePriceData( appStoreProductIds: string[], forceRefresh = false ): Promise<{ [appStoreProductId: string]: ISubscriptionPlanData } | null> { const cacheKey = appStoreProductIds.sort().join(','); const executor = forceRefresh ? executeAndCache : executeWithPersistentCache; return executor(cacheKey, () => this.requestNativeSubscriptionPlans(appStoreProductIds, cacheKey), { storageKeyPrefix: 'joli_subscription_price_' }); } extractCacheableData(response: SubscriptionResponse): SubscriptionCacheData { return { subscriptionTiers: response }; } extractRealTimeData?: undefined; mergeData(): never { throw new Error('SubscriptionService does not support real-time data merging'); } processCachedData(cached: SubscriptionCacheData): SubscriptionResponse { return cached.subscriptionTiers; } // Update native price cache method async updateNativePriceCache(appStoreProductIds: string[]): Promise { await this.getNativePriceData(appStoreProductIds, true); console.log('[SubscriptionService] Updated native price cache for products:', appStoreProductIds); } // Clear native price cache static method static clearNativePriceCache(): void { this.nativePriceCache.clear(); this.nativePriceCacheTimestamp.clear(); console.log('[SubscriptionService] Cleared native price cache'); } } // Base Subscription Service class class BaseSubscriptionService extends RequestCacheService< SubscriptionRequest, SubscriptionResponse, SubscriptionCacheData, never > { private pendingSubscriptions = new Map< string, Deferred> >(); private subInfoCacheKeys: string[] = []; constructor(private apiEndpoint: string) { super(new SubscriptionRequestAdapter(), { duration: 30 * 60 * 1000, // 30分钟缓存订阅档位 timeout: 3000 // 3秒超时 }); this.listenSubscriptionPanelStateChange(); } async getSubInfo(): Promise { try { const result = await this.request(this.apiEndpoint); this.subInfoCacheKeys = result?.map((tier) => tier.appStoreProductId ?? '') ?? []; return result; } catch (error) { console.warn(`[SubscriptionService] getSubInfo failed for ${this.apiEndpoint}:`, error); throw error; } } // 强制刷新订阅档位信息 async refreshSubInfo(): Promise { return this.forceRequest(this.apiEndpoint); } async updateNativePriceCache(appStoreProductIds: string[]): Promise { const adapter = this.requestAdapter as SubscriptionRequestAdapter; await adapter.updateNativePriceCache(appStoreProductIds); } async updateAllNativePriceCache(): Promise { try { if (this.subInfoCacheKeys.length > 0) { await this.updateNativePriceCache(this.subInfoCacheKeys); this.forceRequest(this.apiEndpoint); } } catch (error) { console.warn('[SubscriptionService] Failed to update all native price cache:', error); throw error; } } // subscribe async subscribe(params: { basePlanId: string; nativeProductId: string; appStoreProductId: string; }): Promise<{ subPlanId: string; message: string; result: 'SUCCESS' | 'FAILED' | 'FAILURE_SUBSCRIPTED' }> { console.log('[SubscriptionService] subscribe params', subscriptionHelper); // Start subscription and compensation polling in parallel const abortController = new AbortController(); const subscribePromise = subscriptionHelper.invokeSubscription('SUB_APP', { productId: params.nativeProductId, appStoreProductId: params.appStoreProductId, planId: params.basePlanId }); const compensationPromise = this.pollSubscriptionStatus(abortController.signal); // Wait for both to complete or first success const result = (await Promise.race([ subscribePromise.then((subscribeResult) => { // Cancel polling when subscribe completes abortController.abort(); return { result: subscribeResult.code as 'SUCCESS' | 'FAILED' | 'FAILURE_SUBSCRIPTED', subPlanId: subscribeResult.data?.subPlanId, message: subscribeResult.message }; }), compensationPromise.then((pollingResult) => { if (pollingResult?.result === 'SUCCESS') { abortController.abort(); return pollingResult; } // If polling fails or times out, wait forever (let subscribe resolve) return new Promise(() => { console.log('[SubscriptionPolling] Timeout for subscriptionService.subscribe completion'); }); }) ])) as { result: 'SUCCESS' | 'FAILED' | 'FAILURE_SUBSCRIPTED'; subPlanId: string; message?: string }; return { result: result.result, subPlanId: result.subPlanId, message: result.message ?? '' }; } // Polling function for subscription status compensation private async pollSubscriptionStatus(abortSignal: AbortSignal): Promise<{ result: 'SUCCESS' | 'FAILED' | 'FAILURE_SUBSCRIPTED'; subPlanId: string; message?: string; } | void> { const maxAttempts = 10; // Poll for 50 seconds max const interval = 5000; // 5 second intervals for (let attempt = 0; attempt < maxAttempts; attempt++) { if (abortSignal.aborted) { console.log('[SubscriptionPolling] Aborted due to subscribe completion'); return; } try { const { response } = await fetch>('/api/subs/info', { method: 'GET', appendHostCookie: true, responseType: 'json' }); if (response.data?.data?.status === 'ACTIVE') { return { result: 'SUCCESS', subPlanId: response.data.data.subPlanId, message: 'Subscription activated via polling' }; } } catch (error) { console.warn(`[SubscriptionPolling] Attempt ${attempt + 1} failed:`, error); } if (attempt < maxAttempts - 1 && !abortSignal.aborted) { await sleep(interval); } } } async invokeSubscriptionPanel(): Promise< StandardResponse<{ result: 'SUCCESS' | 'FAILED'; subPlanId?: string }> > { const deferred = new Deferred>(); let targetOrderUUID: string | undefined; try { console.log('[invokeSubscriptionPanel] requestSubscriptionSync params'); const response = invokeNative('invokeSubscriptionPanelSync', { type: 'game' }); targetOrderUUID = response.data?.subUUID; if (!targetOrderUUID) { throw createSubscriptionInternalError( 'orderUUID is null', SubscriptionErrorCodeMap.SubscriptionFailed ); } this.pendingSubscriptions.set(targetOrderUUID, deferred); } catch (e) { throw createSubscriptionInternalError( JSON.stringify(e) ?? 'subscription panel failed', SubscriptionErrorCodeMap.SubscriptionFailed ); } return deferred.promise; } // get tier by product id async getTierByProductId( productId: string ): Promise<{ nativeProductId: string; appStoreProductId: string; basePlanId: string } | undefined> { const serverSubInfos = await this.getSubInfo(); const serverSubInfo = serverSubInfos?.find((tier) => tier.productId === productId); if (!serverSubInfo) { return undefined; } const nativeSubInfos = SubscriptionRequestAdapter.getNativePriceCache( (serverSubInfos ?? []).map((tier) => tier.appStoreProductId ?? '') ); if (!nativeSubInfos) { return undefined; } const nativeSubInfo = nativeSubInfos[serverSubInfo.appStoreProductId ?? '']; if (!nativeSubInfo) { return undefined; } console.log('[SubscriptionService] getTierByProductId nativeSubInfo', nativeSubInfo); return { nativeProductId: nativeSubInfo.productId, appStoreProductId: serverSubInfo.appStoreProductId!, basePlanId: nativeSubInfo.basePlanId }; } private listenSubscriptionPanelStateChange(): void { onNative('onSubscriptionPanelStateChange', (data) => { console.log('[onSubscriptionPanelStateChange] data', data); const deferred = this.pendingSubscriptions.get(data.subUUID); if (!deferred) { return; } if (data.status === 'SUCCESS') { deferred.resolve({ code: 'SUCCESS' as ResponseType, message: 'subscription panel success', data: { result: 'SUCCESS', subPlanId: data.orderResponse?.subPlanId } }); } else { deferred.resolve({ code: 'FAILED' as ResponseType, message: 'subscription panel failed', data: { result: 'FAILED' } }); } }); } } // Subscription Service implementation export class SubscriptionService extends BaseSubscriptionService { constructor() { super('/api/subs/pricing'); } } // Service instance management let subscriptionServiceInstance: SubscriptionService | null = null; // Create subscription service instance export const createSubscriptionService = (): SubscriptionService => { if (subscriptionServiceInstance) { return subscriptionServiceInstance; } subscriptionServiceInstance = new SubscriptionService(); return subscriptionServiceInstance; }; // Export utility methods for testing export const clearSubscriptionNativePriceCache = (): void => { SubscriptionRequestAdapter.clearNativePriceCache(); };