@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.
Files changed (66) hide show
  1. package/.rush/temp/package-deps_build.json +38 -28
  2. package/dist/common/rewards/registers/use-gem-only.d.ts +2 -1
  3. package/dist/common/rewards/registers/use-gem.d.ts +2 -1
  4. package/dist/common/rewards/registers/use-jolicoin-only.d.ts +2 -1
  5. package/dist/common/rewards/registers/use-jolicoin.d.ts +2 -1
  6. package/dist/common/rewards/registers/use-subscription.d.ts +2 -1
  7. package/dist/common/rewards/registers/utils/coins/jolicoin/jolicoin-handler.d.ts +1 -0
  8. package/dist/common/rewards/registers/utils/coins/joligem/gem-handler.d.ts +2 -1
  9. package/dist/common/rewards/registers/utils/common.d.ts +1 -0
  10. package/dist/common/rewards/registers/utils/subscription/sub-handler.d.ts +2 -1
  11. package/dist/common/rewards/reward-emitter.d.ts +8 -0
  12. package/dist/common/rewards/type.d.ts +1 -1
  13. package/dist/common/utils/index.d.ts +1 -1
  14. package/dist/index.js +25 -25
  15. package/dist/index.native.js +54 -54
  16. package/dist/native/payment/utils/__tests__/cache-with-storage.test.d.ts +1 -0
  17. package/dist/native/payment/utils/cache-with-storage.d.ts +7 -0
  18. package/dist/native/rewards/check-frequency.d.ts +8 -0
  19. package/dist/native/rewards/index.d.ts +1 -0
  20. package/dist/native/rewards/ui/subscription-modal.d.ts +1 -0
  21. package/dist/native/subscription/index.d.ts +3 -0
  22. package/dist/native/subscription/registers/base.d.ts +22 -0
  23. package/dist/native/subscription/registers/sub-app.d.ts +21 -0
  24. package/dist/native/subscription/registers/type.d.ts +10 -0
  25. package/dist/native/subscription/subscription-helper.d.ts +22 -0
  26. package/dist/native/subscription/subscription-service.d.ts +43 -0
  27. package/dist/native/subscription/type.d.ts +18 -0
  28. package/implement.build.log +2 -2
  29. package/package.json +5 -5
  30. package/src/common/context/index.ts +4 -1
  31. package/src/common/rewards/cached-fetch-reward.ts +16 -1
  32. package/src/common/rewards/registers/use-gem-only.ts +5 -2
  33. package/src/common/rewards/registers/use-gem.ts +5 -2
  34. package/src/common/rewards/registers/use-jolicoin-only.ts +5 -2
  35. package/src/common/rewards/registers/use-jolicoin.ts +5 -2
  36. package/src/common/rewards/registers/use-subscription.ts +5 -2
  37. package/src/common/rewards/registers/utils/coins/jolicoin/jolicoin-handler.ts +33 -11
  38. package/src/common/rewards/registers/utils/coins/joligem/gem-handler.ts +34 -13
  39. package/src/common/rewards/registers/utils/common.ts +9 -0
  40. package/src/common/rewards/registers/utils/subscription/commands/use-subscription.ts +16 -0
  41. package/src/common/rewards/registers/utils/subscription/sub-handler.ts +23 -7
  42. package/src/common/rewards/reward-emitter.ts +8 -0
  43. package/src/common/rewards/type.ts +1 -1
  44. package/src/common/utils/index.ts +1 -1
  45. package/src/h5/api/ads.ts +6 -3
  46. package/src/h5/bootstrap/auth/__tests__/auth.test.ts +15 -9
  47. package/src/h5/bootstrap/auth/sub.ts +1 -1
  48. package/src/native/api/ads.ts +43 -6
  49. package/src/native/api/call-host-method.ts +5 -61
  50. package/src/native/api/login.ts +22 -7
  51. package/src/native/api/payment.ts +78 -3
  52. package/src/native/payment/__tests__/payment-service-simple.test.ts +14 -1
  53. package/src/native/payment/payment-service.ts +26 -27
  54. package/src/native/payment/utils/__tests__/cache-with-storage.test.ts +414 -0
  55. package/src/native/payment/utils/cache-with-storage.ts +112 -0
  56. package/src/native/rewards/check-frequency.ts +6 -0
  57. package/src/native/rewards/index.ts +1 -0
  58. package/src/native/rewards/ui/payment-modal.ts +2 -2
  59. package/src/native/rewards/ui/subscription-modal.ts +81 -0
  60. package/src/native/subscription/index.ts +12 -0
  61. package/src/native/subscription/registers/base.ts +88 -0
  62. package/src/native/subscription/registers/sub-app.ts +258 -0
  63. package/src/native/subscription/registers/type.ts +13 -0
  64. package/src/native/subscription/subscription-helper.ts +53 -0
  65. package/src/native/subscription/subscription-service.ts +339 -0
  66. 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 = 30 * 60 * 1000; // 30分钟缓存
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
- console.log('[PaymentService] Fetching fresh native price data');
106
-
107
- try {
108
- const { data } = await applyNative('requestProductDetailsAsync', {
109
- appStoreProductIds
110
- });
111
-
112
- if (data) {
113
- // cache
114
- PaymentRequestAdapter.nativePriceCache.set(cacheKey, data);
115
- PaymentRequestAdapter.nativePriceCacheTimestamp.set(cacheKey, Date.now());
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');
@@ -5,3 +5,4 @@
5
5
  import './ui/use-modal';
6
6
  import './ui/payment-modal';
7
7
  import './ui/unlogin-modal';
8
+ import './ui/subscription-modal';
@@ -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.info('payment failed', error);
282
+ console.error('payment failed', error);
283
283
  rewardsEmitter.emit(PaymentResultEventName, { paymentResult: 'FAILED', currency: params.currency });
284
284
  }
285
285
  }