@jolibox/implement 1.2.7 → 1.2.9-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/package-deps_build.json +38 -28
- package/dist/common/rewards/registers/use-gem-only.d.ts +2 -1
- package/dist/common/rewards/registers/use-gem.d.ts +2 -1
- package/dist/common/rewards/registers/use-jolicoin-only.d.ts +2 -1
- package/dist/common/rewards/registers/use-jolicoin.d.ts +2 -1
- package/dist/common/rewards/registers/use-subscription.d.ts +2 -1
- package/dist/common/rewards/registers/utils/coins/jolicoin/jolicoin-handler.d.ts +1 -0
- package/dist/common/rewards/registers/utils/coins/joligem/gem-handler.d.ts +2 -1
- package/dist/common/rewards/registers/utils/common.d.ts +1 -0
- package/dist/common/rewards/registers/utils/subscription/sub-handler.d.ts +2 -1
- package/dist/common/rewards/reward-emitter.d.ts +8 -0
- package/dist/common/rewards/type.d.ts +1 -1
- package/dist/common/utils/index.d.ts +1 -1
- package/dist/index.js +25 -25
- package/dist/index.native.js +54 -54
- package/dist/native/payment/utils/__tests__/cache-with-storage.test.d.ts +1 -0
- package/dist/native/payment/utils/cache-with-storage.d.ts +7 -0
- package/dist/native/rewards/check-frequency.d.ts +8 -0
- package/dist/native/rewards/index.d.ts +1 -0
- package/dist/native/rewards/ui/subscription-modal.d.ts +1 -0
- package/dist/native/subscription/index.d.ts +3 -0
- package/dist/native/subscription/registers/base.d.ts +22 -0
- package/dist/native/subscription/registers/sub-app.d.ts +21 -0
- package/dist/native/subscription/registers/type.d.ts +10 -0
- package/dist/native/subscription/subscription-helper.d.ts +22 -0
- package/dist/native/subscription/subscription-service.d.ts +43 -0
- package/dist/native/subscription/type.d.ts +18 -0
- package/implement.build.log +2 -2
- package/package.json +5 -5
- package/src/common/context/index.ts +4 -1
- package/src/common/rewards/cached-fetch-reward.ts +16 -1
- package/src/common/rewards/registers/use-gem-only.ts +5 -2
- package/src/common/rewards/registers/use-gem.ts +5 -2
- package/src/common/rewards/registers/use-jolicoin-only.ts +5 -2
- package/src/common/rewards/registers/use-jolicoin.ts +5 -2
- package/src/common/rewards/registers/use-subscription.ts +5 -2
- package/src/common/rewards/registers/utils/coins/jolicoin/jolicoin-handler.ts +33 -11
- package/src/common/rewards/registers/utils/coins/joligem/gem-handler.ts +34 -13
- package/src/common/rewards/registers/utils/common.ts +9 -0
- package/src/common/rewards/registers/utils/subscription/commands/use-subscription.ts +16 -0
- package/src/common/rewards/registers/utils/subscription/sub-handler.ts +23 -7
- package/src/common/rewards/reward-emitter.ts +8 -0
- package/src/common/rewards/type.ts +1 -1
- package/src/common/utils/index.ts +1 -1
- package/src/h5/api/ads.ts +6 -3
- package/src/h5/bootstrap/auth/__tests__/auth.test.ts +15 -9
- package/src/h5/bootstrap/auth/sub.ts +1 -1
- package/src/native/api/ads.ts +43 -6
- package/src/native/api/call-host-method.ts +5 -61
- package/src/native/api/login.ts +22 -7
- package/src/native/api/payment.ts +78 -3
- package/src/native/payment/__tests__/payment-service-simple.test.ts +14 -1
- package/src/native/payment/payment-service.ts +26 -27
- package/src/native/payment/utils/__tests__/cache-with-storage.test.ts +414 -0
- package/src/native/payment/utils/cache-with-storage.ts +112 -0
- package/src/native/rewards/check-frequency.ts +6 -0
- package/src/native/rewards/index.ts +1 -0
- package/src/native/rewards/ui/payment-modal.ts +2 -2
- package/src/native/rewards/ui/subscription-modal.ts +81 -0
- package/src/native/subscription/index.ts +12 -0
- package/src/native/subscription/registers/base.ts +88 -0
- package/src/native/subscription/registers/sub-app.ts +258 -0
- package/src/native/subscription/registers/type.ts +13 -0
- package/src/native/subscription/subscription-helper.ts +53 -0
- package/src/native/subscription/subscription-service.ts +339 -0
- package/src/native/subscription/type.ts +18 -0
|
@@ -6,7 +6,7 @@ import { applyNative } from '@jolibox/native-bridge';
|
|
|
6
6
|
import { innerFetch as fetch } from '@/native/network';
|
|
7
7
|
import type { PaymentResult } from './payment-helper';
|
|
8
8
|
import { RequestCacheService, RequestAdapter } from '@jolibox/common';
|
|
9
|
-
|
|
9
|
+
import { executeWithPersistentCache } from './utils/cache-with-storage';
|
|
10
10
|
type PaymentPurchaseType = 'JOLI_COIN' | 'JOLI_GEM';
|
|
11
11
|
|
|
12
12
|
// Request/Response interfaces for RequestCacheService
|
|
@@ -51,7 +51,7 @@ class PaymentRequestAdapter
|
|
|
51
51
|
{
|
|
52
52
|
// applyNative 的缓存
|
|
53
53
|
private static nativePriceCache = new Map<string, { [appStoreProductId: string]: { price: string } }>();
|
|
54
|
-
private static readonly NATIVE_PRICE_CACHE_DURATION =
|
|
54
|
+
private static readonly NATIVE_PRICE_CACHE_DURATION = 1 * 24 * 60 * 60 * 1000; // 1 day
|
|
55
55
|
private static nativePriceCacheTimestamp = new Map<string, number>();
|
|
56
56
|
|
|
57
57
|
async execute(endpoint: string, _options?: PaymentRequest): Promise<PaymentResponse> {
|
|
@@ -92,34 +92,33 @@ class PaymentRequestAdapter
|
|
|
92
92
|
private async getNativePriceData(
|
|
93
93
|
appStoreProductIds: string[]
|
|
94
94
|
): Promise<{ [appStoreProductId: string]: { price: string } } | null> {
|
|
95
|
-
// 检查缓存
|
|
96
95
|
const cacheKey = appStoreProductIds.sort().join(',');
|
|
97
|
-
const cached = PaymentRequestAdapter.nativePriceCache.get(cacheKey);
|
|
98
|
-
const cacheTime = PaymentRequestAdapter.nativePriceCacheTimestamp.get(cacheKey) || 0;
|
|
99
|
-
|
|
100
|
-
if (cached && Date.now() - cacheTime < PaymentRequestAdapter.NATIVE_PRICE_CACHE_DURATION) {
|
|
101
|
-
console.log('[PaymentService] Using cached native price data');
|
|
102
|
-
return cached;
|
|
103
|
-
}
|
|
104
96
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
97
|
+
return executeWithPersistentCache(
|
|
98
|
+
cacheKey,
|
|
99
|
+
async () => {
|
|
100
|
+
try {
|
|
101
|
+
const { data } = await applyNative('requestProductDetailsAsync', {
|
|
102
|
+
appStoreProductIds
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (data) {
|
|
106
|
+
// cache
|
|
107
|
+
PaymentRequestAdapter.nativePriceCache.set(cacheKey, data);
|
|
108
|
+
PaymentRequestAdapter.nativePriceCacheTimestamp.set(cacheKey, Date.now());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return data;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.warn('[PaymentService] Failed to fetch native product details:', error);
|
|
114
|
+
throw error; // rethrow error, let upper layer handle it
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
cacheDuration: PaymentRequestAdapter.NATIVE_PRICE_CACHE_DURATION,
|
|
119
|
+
storageKeyPrefix: 'joli_native_price_'
|
|
116
120
|
}
|
|
117
|
-
|
|
118
|
-
return data;
|
|
119
|
-
} catch (error) {
|
|
120
|
-
console.warn('[PaymentService] Failed to fetch native product details:', error);
|
|
121
|
-
throw error; // rethrow error, let upper layer handle it
|
|
122
|
-
}
|
|
121
|
+
);
|
|
123
122
|
}
|
|
124
123
|
|
|
125
124
|
extractCacheableData(response: PaymentResponse): PaymentCacheData {
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { executeWithPersistentCache } from '../cache-with-storage';
|
|
2
|
+
import { setGlobalStorage, getGlobalStorage } from '@/native/api/storage';
|
|
3
|
+
|
|
4
|
+
// Mock native bridge to prevent invokeNative errors
|
|
5
|
+
jest.mock('@jolibox/native-bridge', () => ({
|
|
6
|
+
invokeNative: jest.fn(),
|
|
7
|
+
applyNative: jest.fn()
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// Mock storage functions
|
|
11
|
+
jest.mock('@/native/api/storage', () => ({
|
|
12
|
+
setGlobalStorage: jest.fn(),
|
|
13
|
+
getGlobalStorage: jest.fn()
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const mockSetGlobalStorage = setGlobalStorage as jest.MockedFunction<typeof setGlobalStorage>;
|
|
17
|
+
const mockGetGlobalStorage = getGlobalStorage as jest.MockedFunction<typeof getGlobalStorage>;
|
|
18
|
+
|
|
19
|
+
describe('executeWithPersistentCache', () => {
|
|
20
|
+
const mockExecutor = jest.fn();
|
|
21
|
+
const testCacheKey = 'test-key';
|
|
22
|
+
const testData = { value: 'test-data' };
|
|
23
|
+
const mockTimestamp = 1640000000000;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
jest.clearAllMocks();
|
|
27
|
+
jest.spyOn(Date, 'now').mockReturnValue(mockTimestamp);
|
|
28
|
+
jest.spyOn(console, 'log').mockImplementation();
|
|
29
|
+
jest.spyOn(console, 'warn').mockImplementation();
|
|
30
|
+
// Mock 成功的 setGlobalStorage 响应
|
|
31
|
+
mockSetGlobalStorage.mockResolvedValue({
|
|
32
|
+
code: 'SUCCESS',
|
|
33
|
+
message: 'success'
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
jest.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('Cache miss scenarios', () => {
|
|
42
|
+
it('should execute function and cache result when no cached data exists', async () => {
|
|
43
|
+
// Setup: no cached data
|
|
44
|
+
mockGetGlobalStorage.mockResolvedValue({
|
|
45
|
+
code: 'SUCCESS',
|
|
46
|
+
message: 'success',
|
|
47
|
+
data: null
|
|
48
|
+
});
|
|
49
|
+
mockExecutor.mockResolvedValue(testData);
|
|
50
|
+
|
|
51
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor);
|
|
52
|
+
|
|
53
|
+
expect(result).toEqual(testData);
|
|
54
|
+
expect(mockExecutor).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(mockSetGlobalStorage).toHaveBeenCalledWith('joli_cache_test-key', testData);
|
|
56
|
+
expect(mockSetGlobalStorage).toHaveBeenCalledWith(
|
|
57
|
+
'joli_cache_test-key_timestamp',
|
|
58
|
+
mockTimestamp.toString()
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should execute function when cached data is expired', async () => {
|
|
63
|
+
const expiredTimestamp = (mockTimestamp - 60 * 60 * 1000).toString(); // 1 hour ago
|
|
64
|
+
|
|
65
|
+
mockGetGlobalStorage
|
|
66
|
+
.mockResolvedValueOnce({
|
|
67
|
+
code: 'SUCCESS',
|
|
68
|
+
message: 'success',
|
|
69
|
+
data: JSON.stringify(testData)
|
|
70
|
+
})
|
|
71
|
+
.mockResolvedValueOnce({
|
|
72
|
+
code: 'SUCCESS',
|
|
73
|
+
message: 'success',
|
|
74
|
+
data: expiredTimestamp
|
|
75
|
+
});
|
|
76
|
+
mockExecutor.mockResolvedValue({ value: 'fresh-data' });
|
|
77
|
+
|
|
78
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor, {
|
|
79
|
+
cacheDuration: 30 * 60 * 1000 // 30 minutes
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual({ value: 'fresh-data' });
|
|
83
|
+
expect(mockExecutor).toHaveBeenCalledTimes(1);
|
|
84
|
+
// Should clear expired cache
|
|
85
|
+
expect(mockSetGlobalStorage).toHaveBeenCalledWith('joli_cache_test-key', null);
|
|
86
|
+
expect(mockSetGlobalStorage).toHaveBeenCalledWith('joli_cache_test-key_timestamp', null);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('Cache hit scenarios', () => {
|
|
91
|
+
it('should return cached data when valid cache exists', async () => {
|
|
92
|
+
const validTimestamp = (mockTimestamp - 10 * 60 * 1000).toString(); // 10 minutes ago
|
|
93
|
+
|
|
94
|
+
mockGetGlobalStorage
|
|
95
|
+
.mockResolvedValueOnce({
|
|
96
|
+
code: 'SUCCESS',
|
|
97
|
+
message: 'success',
|
|
98
|
+
data: JSON.stringify(testData)
|
|
99
|
+
})
|
|
100
|
+
.mockResolvedValueOnce({
|
|
101
|
+
code: 'SUCCESS',
|
|
102
|
+
message: 'success',
|
|
103
|
+
data: validTimestamp
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor, {
|
|
107
|
+
cacheDuration: 30 * 60 * 1000 // 30 minutes
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual(testData);
|
|
111
|
+
expect(mockExecutor).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should return cached data and update in background when cache is valid but not never-expire', async () => {
|
|
115
|
+
const validTimestamp = (mockTimestamp - 10 * 60 * 1000).toString();
|
|
116
|
+
|
|
117
|
+
mockGetGlobalStorage
|
|
118
|
+
.mockResolvedValueOnce({
|
|
119
|
+
code: 'SUCCESS',
|
|
120
|
+
message: 'success',
|
|
121
|
+
data: JSON.stringify(testData)
|
|
122
|
+
})
|
|
123
|
+
.mockResolvedValueOnce({
|
|
124
|
+
code: 'SUCCESS',
|
|
125
|
+
message: 'success',
|
|
126
|
+
data: validTimestamp
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// 创建一个新的 executor 实例以避免跨测试的状态污染
|
|
130
|
+
const backgroundExecutor = jest.fn().mockResolvedValue({ value: 'updated-data' });
|
|
131
|
+
|
|
132
|
+
const result = await executeWithPersistentCache(testCacheKey, backgroundExecutor, {
|
|
133
|
+
cacheDuration: 30 * 60 * 1000
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result).toEqual(testData);
|
|
137
|
+
|
|
138
|
+
// 等待更长时间确保背景更新完成
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
140
|
+
|
|
141
|
+
expect(backgroundExecutor).toHaveBeenCalledTimes(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('Never-expire cache', () => {
|
|
146
|
+
it('should return cached data without background update when cacheDuration is Infinity', async () => {
|
|
147
|
+
const oldTimestamp = (mockTimestamp - 24 * 60 * 60 * 1000).toString(); // 24 hours ago
|
|
148
|
+
|
|
149
|
+
mockGetGlobalStorage
|
|
150
|
+
.mockResolvedValueOnce({
|
|
151
|
+
code: 'SUCCESS',
|
|
152
|
+
message: 'success',
|
|
153
|
+
data: JSON.stringify(testData)
|
|
154
|
+
})
|
|
155
|
+
.mockResolvedValueOnce({
|
|
156
|
+
code: 'SUCCESS',
|
|
157
|
+
message: 'success',
|
|
158
|
+
data: oldTimestamp
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor, {
|
|
162
|
+
cacheDuration: Infinity
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(result).toEqual(testData);
|
|
166
|
+
expect(mockExecutor).not.toHaveBeenCalled();
|
|
167
|
+
|
|
168
|
+
// Wait to ensure no background update
|
|
169
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
170
|
+
expect(mockExecutor).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should return cached data without background update when cacheDuration is -1', async () => {
|
|
174
|
+
const oldTimestamp = (mockTimestamp - 24 * 60 * 60 * 1000).toString();
|
|
175
|
+
|
|
176
|
+
mockGetGlobalStorage
|
|
177
|
+
.mockResolvedValueOnce({
|
|
178
|
+
code: 'SUCCESS',
|
|
179
|
+
message: 'success',
|
|
180
|
+
data: JSON.stringify(testData)
|
|
181
|
+
})
|
|
182
|
+
.mockResolvedValueOnce({
|
|
183
|
+
code: 'SUCCESS',
|
|
184
|
+
message: 'success',
|
|
185
|
+
data: oldTimestamp
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor, {
|
|
189
|
+
cacheDuration: -1
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(result).toEqual(testData);
|
|
193
|
+
expect(mockExecutor).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should use default Infinity cacheDuration when not specified', async () => {
|
|
197
|
+
const oldTimestamp = (mockTimestamp - 24 * 60 * 60 * 1000).toString();
|
|
198
|
+
|
|
199
|
+
mockGetGlobalStorage
|
|
200
|
+
.mockResolvedValueOnce({
|
|
201
|
+
code: 'SUCCESS',
|
|
202
|
+
message: 'success',
|
|
203
|
+
data: JSON.stringify(testData)
|
|
204
|
+
})
|
|
205
|
+
.mockResolvedValueOnce({
|
|
206
|
+
code: 'SUCCESS',
|
|
207
|
+
message: 'success',
|
|
208
|
+
data: oldTimestamp
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor);
|
|
212
|
+
|
|
213
|
+
expect(result).toEqual(testData);
|
|
214
|
+
expect(mockExecutor).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('Storage options', () => {
|
|
219
|
+
it('should use custom storage key prefix', async () => {
|
|
220
|
+
mockGetGlobalStorage.mockResolvedValue({
|
|
221
|
+
code: 'SUCCESS',
|
|
222
|
+
message: 'success',
|
|
223
|
+
data: null
|
|
224
|
+
});
|
|
225
|
+
mockExecutor.mockResolvedValue(testData);
|
|
226
|
+
|
|
227
|
+
await executeWithPersistentCache(testCacheKey, mockExecutor, {
|
|
228
|
+
storageKeyPrefix: 'custom_prefix_'
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(mockGetGlobalStorage).toHaveBeenCalledWith('custom_prefix_test-key');
|
|
232
|
+
expect(mockGetGlobalStorage).toHaveBeenCalledWith('custom_prefix_test-key_timestamp');
|
|
233
|
+
expect(mockSetGlobalStorage).toHaveBeenCalledWith('custom_prefix_test-key', testData);
|
|
234
|
+
expect(mockSetGlobalStorage).toHaveBeenCalledWith(
|
|
235
|
+
'custom_prefix_test-key_timestamp',
|
|
236
|
+
mockTimestamp.toString()
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('Error handling', () => {
|
|
242
|
+
it('should handle getGlobalStorage error and fallback to executor', async () => {
|
|
243
|
+
mockGetGlobalStorage.mockRejectedValue(new Error('Storage read error'));
|
|
244
|
+
mockExecutor.mockResolvedValue(testData);
|
|
245
|
+
|
|
246
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor);
|
|
247
|
+
|
|
248
|
+
expect(result).toEqual(testData);
|
|
249
|
+
expect(mockExecutor).toHaveBeenCalledTimes(1);
|
|
250
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
251
|
+
'[Cache] Failed to read from localStorage for key:',
|
|
252
|
+
testCacheKey,
|
|
253
|
+
expect.any(Error)
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should handle setGlobalStorage error but still return data', async () => {
|
|
258
|
+
mockGetGlobalStorage.mockResolvedValue({
|
|
259
|
+
code: 'SUCCESS',
|
|
260
|
+
message: 'success',
|
|
261
|
+
data: null
|
|
262
|
+
});
|
|
263
|
+
mockSetGlobalStorage.mockRejectedValue(new Error('Storage write error'));
|
|
264
|
+
mockExecutor.mockResolvedValue(testData);
|
|
265
|
+
|
|
266
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor);
|
|
267
|
+
|
|
268
|
+
expect(result).toEqual(testData);
|
|
269
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
270
|
+
'[Cache] Failed to save to localStorage for key:',
|
|
271
|
+
testCacheKey,
|
|
272
|
+
expect.any(Error)
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should propagate executor errors', async () => {
|
|
277
|
+
mockGetGlobalStorage.mockResolvedValue({
|
|
278
|
+
code: 'SUCCESS',
|
|
279
|
+
message: 'success',
|
|
280
|
+
data: null
|
|
281
|
+
});
|
|
282
|
+
const executorError = new Error('Executor failed');
|
|
283
|
+
mockExecutor.mockRejectedValue(executorError);
|
|
284
|
+
|
|
285
|
+
await expect(executeWithPersistentCache(testCacheKey, mockExecutor)).rejects.toThrow('Executor failed');
|
|
286
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
287
|
+
'[Cache] Executor failed for key:',
|
|
288
|
+
testCacheKey,
|
|
289
|
+
executorError
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should handle JSON parse error in cached data', async () => {
|
|
294
|
+
mockGetGlobalStorage
|
|
295
|
+
.mockResolvedValueOnce({
|
|
296
|
+
code: 'SUCCESS',
|
|
297
|
+
message: 'success',
|
|
298
|
+
data: 'invalid-json{'
|
|
299
|
+
})
|
|
300
|
+
.mockResolvedValueOnce({
|
|
301
|
+
code: 'SUCCESS',
|
|
302
|
+
message: 'success',
|
|
303
|
+
data: mockTimestamp.toString()
|
|
304
|
+
});
|
|
305
|
+
mockExecutor.mockResolvedValue(testData);
|
|
306
|
+
|
|
307
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor);
|
|
308
|
+
|
|
309
|
+
expect(result).toEqual(testData);
|
|
310
|
+
expect(mockExecutor).toHaveBeenCalledTimes(1);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should handle background update errors gracefully', async () => {
|
|
314
|
+
const validTimestamp = (mockTimestamp - 10 * 60 * 1000).toString();
|
|
315
|
+
|
|
316
|
+
mockGetGlobalStorage
|
|
317
|
+
.mockResolvedValueOnce({
|
|
318
|
+
code: 'SUCCESS',
|
|
319
|
+
message: 'success',
|
|
320
|
+
data: JSON.stringify(testData)
|
|
321
|
+
})
|
|
322
|
+
.mockResolvedValueOnce({
|
|
323
|
+
code: 'SUCCESS',
|
|
324
|
+
message: 'success',
|
|
325
|
+
data: validTimestamp
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const backgroundExecutor = jest.fn().mockRejectedValue(new Error('Background update failed'));
|
|
329
|
+
|
|
330
|
+
const result = await executeWithPersistentCache(testCacheKey, backgroundExecutor, {
|
|
331
|
+
cacheDuration: 30 * 60 * 1000
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(result).toEqual(testData);
|
|
335
|
+
|
|
336
|
+
// Wait for background update to fail
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
338
|
+
|
|
339
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
340
|
+
'[Cache] Background cache update failed for key:',
|
|
341
|
+
testCacheKey,
|
|
342
|
+
expect.any(Error)
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('Edge cases', () => {
|
|
348
|
+
it('should handle missing timestamp in cache', async () => {
|
|
349
|
+
mockGetGlobalStorage
|
|
350
|
+
.mockResolvedValueOnce({
|
|
351
|
+
code: 'SUCCESS',
|
|
352
|
+
message: 'success',
|
|
353
|
+
data: JSON.stringify(testData)
|
|
354
|
+
})
|
|
355
|
+
.mockResolvedValueOnce({
|
|
356
|
+
code: 'SUCCESS',
|
|
357
|
+
message: 'success',
|
|
358
|
+
data: null
|
|
359
|
+
}); // Missing timestamp
|
|
360
|
+
mockExecutor.mockResolvedValue({ value: 'fresh-data' });
|
|
361
|
+
|
|
362
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor, {
|
|
363
|
+
cacheDuration: 30 * 60 * 1000
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(result).toEqual({ value: 'fresh-data' });
|
|
367
|
+
expect(mockExecutor).toHaveBeenCalledTimes(1);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should handle invalid timestamp format', async () => {
|
|
371
|
+
mockGetGlobalStorage
|
|
372
|
+
.mockResolvedValueOnce({
|
|
373
|
+
code: 'SUCCESS',
|
|
374
|
+
message: 'success',
|
|
375
|
+
data: JSON.stringify(testData)
|
|
376
|
+
})
|
|
377
|
+
.mockResolvedValueOnce({
|
|
378
|
+
code: 'SUCCESS',
|
|
379
|
+
message: 'success',
|
|
380
|
+
data: 'invalid-timestamp'
|
|
381
|
+
});
|
|
382
|
+
mockExecutor.mockResolvedValue({ value: 'fresh-data' });
|
|
383
|
+
|
|
384
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor, {
|
|
385
|
+
cacheDuration: 30 * 60 * 1000
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(result).toEqual({ value: 'fresh-data' });
|
|
389
|
+
expect(mockExecutor).toHaveBeenCalledTimes(1);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('should handle zero cacheDuration (immediate expiration)', async () => {
|
|
393
|
+
mockGetGlobalStorage
|
|
394
|
+
.mockResolvedValueOnce({
|
|
395
|
+
code: 'SUCCESS',
|
|
396
|
+
message: 'success',
|
|
397
|
+
data: JSON.stringify(testData)
|
|
398
|
+
})
|
|
399
|
+
.mockResolvedValueOnce({
|
|
400
|
+
code: 'SUCCESS',
|
|
401
|
+
message: 'success',
|
|
402
|
+
data: mockTimestamp.toString()
|
|
403
|
+
});
|
|
404
|
+
mockExecutor.mockResolvedValue({ value: 'fresh-data' });
|
|
405
|
+
|
|
406
|
+
const result = await executeWithPersistentCache(testCacheKey, mockExecutor, {
|
|
407
|
+
cacheDuration: 0
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
expect(result).toEqual({ value: 'fresh-data' });
|
|
411
|
+
expect(mockExecutor).toHaveBeenCalledTimes(1);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { setGlobalStorage, getGlobalStorage } from '@/native/api/storage';
|
|
2
|
+
|
|
3
|
+
export async function executeAndCache<T>(
|
|
4
|
+
cacheKey: string,
|
|
5
|
+
executor: () => Promise<T>,
|
|
6
|
+
options: {
|
|
7
|
+
storageKeyPrefix?: string;
|
|
8
|
+
} = {}
|
|
9
|
+
): Promise<T> {
|
|
10
|
+
const { storageKeyPrefix = 'joli_cache_' } = options;
|
|
11
|
+
|
|
12
|
+
const storageKey = storageKeyPrefix + cacheKey;
|
|
13
|
+
const timestampKey = storageKey + '_timestamp';
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const data = await executor();
|
|
17
|
+
const timestamp = Date.now();
|
|
18
|
+
|
|
19
|
+
// 执行后直接缓存结果
|
|
20
|
+
try {
|
|
21
|
+
if (data !== null && data !== undefined) {
|
|
22
|
+
await setGlobalStorage(storageKey, data);
|
|
23
|
+
await setGlobalStorage(timestampKey, timestamp.toString());
|
|
24
|
+
console.log('[Cache] Cached result for key:', cacheKey);
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.warn('[Cache] Failed to save to localStorage for key:', cacheKey, error);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return data;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.warn('[Cache] Executor failed for key:', cacheKey, error);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function executeWithPersistentCache<T>(
|
|
38
|
+
cacheKey: string,
|
|
39
|
+
executor: () => Promise<T>,
|
|
40
|
+
options: {
|
|
41
|
+
cacheDuration?: number;
|
|
42
|
+
storageKeyPrefix?: string;
|
|
43
|
+
} = {}
|
|
44
|
+
): Promise<T> {
|
|
45
|
+
const {
|
|
46
|
+
cacheDuration = -1, // 永不过期
|
|
47
|
+
storageKeyPrefix = 'joli_cache_'
|
|
48
|
+
} = options;
|
|
49
|
+
|
|
50
|
+
const storageKey = storageKeyPrefix + cacheKey;
|
|
51
|
+
const timestampKey = storageKey + '_timestamp';
|
|
52
|
+
const neverExpires = cacheDuration === Infinity || cacheDuration === -1;
|
|
53
|
+
|
|
54
|
+
// 1. 先尝试从 localStorage 读取数据
|
|
55
|
+
try {
|
|
56
|
+
const { data: dataStr } = await getGlobalStorage(storageKey);
|
|
57
|
+
const { data: timestampStr } = await getGlobalStorage(timestampKey);
|
|
58
|
+
|
|
59
|
+
if (dataStr && timestampStr) {
|
|
60
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
61
|
+
|
|
62
|
+
if (neverExpires || Date.now() - timestamp < cacheDuration) {
|
|
63
|
+
console.log('[Cache] Using localStorage cached data for key:', cacheKey);
|
|
64
|
+
const data = JSON.parse(dataStr);
|
|
65
|
+
|
|
66
|
+
// 如果不是永不过期,后台异步更新缓存(不阻塞返回)
|
|
67
|
+
if (!neverExpires) {
|
|
68
|
+
setTimeout(async () => {
|
|
69
|
+
try {
|
|
70
|
+
console.log('[Cache] Background updating cache for key:', cacheKey);
|
|
71
|
+
const freshData = await executor();
|
|
72
|
+
const newTimestamp = Date.now();
|
|
73
|
+
|
|
74
|
+
await setGlobalStorage(storageKey, freshData);
|
|
75
|
+
await setGlobalStorage(timestampKey, newTimestamp.toString());
|
|
76
|
+
console.log('[Cache] Background cache update completed for key:', cacheKey);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.warn('[Cache] Background cache update failed for key:', cacheKey, error);
|
|
79
|
+
}
|
|
80
|
+
}, 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return data;
|
|
84
|
+
} else {
|
|
85
|
+
await setGlobalStorage(storageKey, null);
|
|
86
|
+
await setGlobalStorage(timestampKey, null);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.warn('[Cache] Failed to read from localStorage for key:', cacheKey, error);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const data = await executor();
|
|
95
|
+
const timestamp = Date.now();
|
|
96
|
+
|
|
97
|
+
// 保存到 localStorage
|
|
98
|
+
try {
|
|
99
|
+
if (data) {
|
|
100
|
+
await setGlobalStorage(storageKey, data);
|
|
101
|
+
await setGlobalStorage(timestampKey, timestamp.toString());
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.warn('[Cache] Failed to save to localStorage for key:', cacheKey, error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return data;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.warn('[Cache] Executor failed for key:', cacheKey, error);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -58,6 +58,9 @@ export const checkUseModalFrequencyGem = createFrequencyChecker('joli_gem_use_mo
|
|
|
58
58
|
export const checkPaymentFrequencyGem = createFrequencyChecker('joli_gem_payment_frequency');
|
|
59
59
|
export const checkUnloginModalFrequencyGem = createFrequencyChecker('joli_gem_unlogin_modal_frequency');
|
|
60
60
|
|
|
61
|
+
// subscription frequency
|
|
62
|
+
export const checkSubscriptionFrequency = createFrequencyChecker('subscription_frequency');
|
|
63
|
+
|
|
61
64
|
// common update frequency
|
|
62
65
|
function createFrequencyUpdater(storageKey: string) {
|
|
63
66
|
return async () => {
|
|
@@ -78,3 +81,6 @@ export const updateUnloginModalFrequency = createFrequencyUpdater('joli_coin_unl
|
|
|
78
81
|
export const updateUseModalFrequencyGem = createFrequencyUpdater('joli_gem_use_modal_frequency');
|
|
79
82
|
export const updatePaymentFrequencyGem = createFrequencyUpdater('joli_gem_payment_frequency');
|
|
80
83
|
export const updateUnloginModalFrequencyGem = createFrequencyUpdater('joli_gem_unlogin_modal_frequency');
|
|
84
|
+
|
|
85
|
+
// subscription frequency
|
|
86
|
+
export const updateSubscriptionFrequency = createFrequencyUpdater('subscription_frequency');
|
|
@@ -143,7 +143,7 @@ rewardsEmitter.on(
|
|
|
143
143
|
const balenceDetails = await paymentService.getProductsInfo();
|
|
144
144
|
loading.hide();
|
|
145
145
|
|
|
146
|
-
if (!balenceDetails) {
|
|
146
|
+
if (!balenceDetails || !balenceDetails.paymentChoices?.length) {
|
|
147
147
|
rewardsEmitter.emit(PaymentResultEventName, { paymentResult: 'FAILED', currency: currencyType });
|
|
148
148
|
return;
|
|
149
149
|
}
|
|
@@ -279,7 +279,7 @@ rewardsEmitter.on(
|
|
|
279
279
|
|
|
280
280
|
await paymentConfig.frequencyUpdater();
|
|
281
281
|
} catch (error) {
|
|
282
|
-
console.
|
|
282
|
+
console.error('payment failed', error);
|
|
283
283
|
rewardsEmitter.emit(PaymentResultEventName, { paymentResult: 'FAILED', currency: params.currency });
|
|
284
284
|
}
|
|
285
285
|
}
|