From fcf1be211f3c77d535ee4d9aa093fb3d2c831885 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 29 Oct 2025 09:44:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(vip):=20=E5=AE=9E=E7=8E=B0VIP=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6=E5=92=8C=E9=A3=9F?= =?UTF-8?q?=E7=89=A9=E8=AF=86=E5=88=AB=E5=8A=9F=E8=83=BD=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加VIP服务权限检查hook,支持免费使用次数限制 - 为食物识别功能添加登录验证和VIP权限检查 - 优化RevenueCat用户标识同步逻辑 - 修复会员购买状态检查的类型安全问题 - 为营养成分分析添加登录验证 --- app/food/food-recognition.tsx | 30 +++++++++ components/NutritionRadarCard.tsx | 2 +- components/model/MembershipModal.tsx | 42 ++++++++++--- contexts/MembershipModalContext.tsx | 46 ++++++++++++-- hooks/useVipService.ts | 91 ++++++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 hooks/useVipService.ts diff --git a/app/food/food-recognition.tsx b/app/food/food-recognition.tsx index b9cc8c1..4ffbcfc 100644 --- a/app/food/food-recognition.tsx +++ b/app/food/food-recognition.tsx @@ -1,8 +1,11 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; +import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useAppDispatch } from '@/hooks/redux'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useCosUpload } from '@/hooks/useCosUpload'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; +import { useVipService } from '@/hooks/useVipService'; import { recognizeFood } from '@/services/foodRecognition'; import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice'; import { Ionicons } from '@expo/vector-icons'; @@ -37,6 +40,11 @@ export default function FoodRecognitionScreen() { const [currentStep, setCurrentStep] = useState<'idle' | 'uploading' | 'recognizing' | 'completed' | 'failed'>('idle'); const dispatch = useAppDispatch(); + // 添加认证和VIP服务相关hooks + const { ensureLoggedIn } = useAuthGuard(); + const { handleServiceAccess } = useVipService(); + const { openMembershipModal } = useMembershipModal(); + // 动画引用 const scaleAnim = useRef(new Animated.Value(1)).current; const fadeAnim = useRef(new Animated.Value(0)).current; @@ -125,6 +133,28 @@ export default function FoodRecognitionScreen() { }) ]).start(); + // 先验证登录状态 + const isLoggedIn = await ensureLoggedIn(); + if (!isLoggedIn) { + return; + } + + // 检查用户是否可以使用 AI 食物识别功能 + const canAccess = handleServiceAccess( + () => { + // 允许使用,继续执行识别流程 + }, + () => { + // 不允许使用,显示会员付费弹窗 + openMembershipModal(); + } + ); + + // 如果用户没有权限,直接返回 + if (!canAccess) { + return; + } + try { setShowRecognitionProcess(true); setRecognitionLogs([]); diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index f7063ef..a89081c 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -301,7 +301,7 @@ export function NutritionRadarCard({ style={styles.foodOptionItem} onPress={() => { triggerLightHaptic(); - router.push(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`); + pushIfAuthedElseLogin(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`); }} activeOpacity={0.7} > diff --git a/components/model/MembershipModal.tsx b/components/model/MembershipModal.tsx index d57f65e..7850819 100644 --- a/components/model/MembershipModal.tsx +++ b/components/model/MembershipModal.tsx @@ -72,6 +72,23 @@ const DEFAULT_PLANS: MembershipPlan[] = [ }, ]; +// RevenueCat 在 JS SDK 中以 string[] 返回 activeSubscriptions,但保持健壮性以防类型变化 +const getActiveSubscriptionIds = (customerInfo: CustomerInfo): string[] => { + const activeSubscriptions = customerInfo.activeSubscriptions as unknown; + + if (Array.isArray(activeSubscriptions)) { + return activeSubscriptions; + } + + if (activeSubscriptions && typeof activeSubscriptions === 'object') { + return Object.values(activeSubscriptions as Record).filter( + (value): value is string => typeof value === 'string' + ); + } + + return []; +}; + // 权限类型枚举 type PermissionType = 'exclusive' | 'limited' | 'unlimited'; @@ -356,10 +373,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members const listener = (customerInfo: CustomerInfo) => { log.info('购买状态变化监听器触发', { customerInfo, visible }); + const activeSubscriptionIds = getActiveSubscriptionIds(customerInfo); // 检查是否有有效的购买记录 const hasActiveEntitlements = Object.keys(customerInfo.entitlements.active).length > 0; const hasNonSubscriptionTransactions = customerInfo.nonSubscriptionTransactions.length > 0; - const hasActiveSubscriptions = Object.keys(customerInfo.activeSubscriptions).length > 0; + const hasActiveSubscriptions = activeSubscriptionIds.length > 0; if (hasActiveEntitlements || hasNonSubscriptionTransactions || hasActiveSubscriptions) { capturePurchaseEvent('success', '监听到购买状态变化', { @@ -368,7 +386,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members hasActiveSubscriptions, activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length, nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length, - activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length, + activeSubscriptionsCount: activeSubscriptionIds.length, modalVisible: visible }); @@ -424,10 +442,12 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members log.info('获取用户购买信息', { customerInfo }); // 记录详细的购买状态日志 + const activeSubscriptionIds = getActiveSubscriptionIds(customerInfo); + captureMessageWithContext('获取用户购买信息成功', { activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length, nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length, - activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length, + activeSubscriptionsCount: activeSubscriptionIds.length, originalAppUserId: customerInfo.originalAppUserId, firstSeen: customerInfo.firstSeen, originalPurchaseDate: customerInfo.originalPurchaseDate, @@ -451,7 +471,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members }); // 检查订阅 - Object.keys(customerInfo.activeSubscriptions).forEach(productId => { + activeSubscriptionIds.forEach(productId => { activePurchasedProductIds.push(productId); log.debug(`激活的订阅: ${productId}`); }); @@ -517,7 +537,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members captureMessageWithContext('用户没有激活的购买记录', { hasEntitlements: Object.keys(customerInfo.entitlements.active).length > 0, hasNonSubscriptions: customerInfo.nonSubscriptionTransactions.length > 0, - hasActiveSubscriptions: Object.keys(customerInfo.activeSubscriptions).length > 0 + hasActiveSubscriptions: activeSubscriptionIds.length > 0 }); log.info('用户没有激活的购买记录,使用默认选择逻辑'); setSelectedProduct(availableProducts[0] ?? null); @@ -611,13 +631,14 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members log.info('购买成功', { customerInfo, productIdentifier }); + const activeSubscriptionIds = getActiveSubscriptionIds(customerInfo); // 记录购买成功事件 capturePurchaseEvent('success', `购买成功: ${productIdentifier}`, { productIdentifier, hasActiveEntitlements: Object.keys(customerInfo.entitlements.active).length > 0, activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length, nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length, - activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length + activeSubscriptionsCount: activeSubscriptionIds.length }); // 购买成功后,监听器会自动处理后续逻辑(刷新用户信息、关闭弹窗等) @@ -687,10 +708,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members const customerInfo = await Purchases.restorePurchases(); log.info('恢复购买结果', { customerInfo }); + const activeSubscriptionIds = getActiveSubscriptionIds(customerInfo); captureMessageWithContext('恢复购买结果', { activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length, nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length, - activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length, + activeSubscriptionsCount: activeSubscriptionIds.length, managementUrl: customerInfo.managementURL, originalAppUserId: customerInfo.originalAppUserId }); @@ -698,7 +720,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members // 检查是否有有效的购买记录 const hasActiveEntitlements = Object.keys(customerInfo.entitlements.active).length > 0; const hasNonSubscriptionTransactions = customerInfo.nonSubscriptionTransactions.length > 0; - const hasActiveSubscriptions = Object.keys(customerInfo.activeSubscriptions).length > 0; + const hasActiveSubscriptions = activeSubscriptionIds.length > 0; if (hasActiveEntitlements || hasNonSubscriptionTransactions || hasActiveSubscriptions) { // 检查具体的购买内容 @@ -716,7 +738,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members }); // 检查订阅 - Object.keys(customerInfo.activeSubscriptions).forEach(productId => { + activeSubscriptionIds.forEach(productId => { restoredProducts.push(productId); }); @@ -787,7 +809,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members hasActiveSubscriptions, activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length, nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length, - activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length + activeSubscriptionsCount: activeSubscriptionIds.length }); GlobalToast.show({ message: '没有找到购买记录', diff --git a/contexts/MembershipModalContext.tsx b/contexts/MembershipModalContext.tsx index 5f4d43d..381b659 100644 --- a/contexts/MembershipModalContext.tsx +++ b/contexts/MembershipModalContext.tsx @@ -2,6 +2,8 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS import Purchases from 'react-native-purchases'; import { MembershipModal } from '@/components/model/MembershipModal'; +import { useAppSelector } from '@/hooks/redux'; +import { selectUserProfile } from '@/store/userSlice'; import { logger } from '@/utils/logger'; type MembershipModalOptions = { @@ -20,6 +22,9 @@ export function MembershipModalProvider({ children }: { children: React.ReactNod const [pendingSuccessCallback, setPendingSuccessCallback] = useState<(() => void) | undefined>(); const [isInitialized, setIsInitialized] = useState(false); + // 获取用户信息,用于RevenueCat用户标识 + const userProfile = useAppSelector(selectUserProfile); + useEffect(() => { // 直接使用生产环境的 API Key,避免环境变量问题 const iosApiKey = 'appl_lmVvuLWFlXlrEsnvxMzTnKapqcc'; @@ -28,18 +33,51 @@ export function MembershipModalProvider({ children }: { children: React.ReactNod try { // 检查是否已经配置过,避免重复配置 if (!isInitialized) { - await Purchases.configure({ apiKey: iosApiKey }); + // 如果有用户ID,在配置时传入用户标识 + const configOptions: any = { apiKey: iosApiKey }; + + if (userProfile?.id) { + configOptions.appUserID = userProfile.id; + logger.info('[MembershipModalProvider] RevenueCat SDK 初始化,使用用户ID:', userProfile.id); + } else { + logger.info('[MembershipModalProvider] RevenueCat SDK 初始化,未设置用户ID'); + } + + await Purchases.configure(configOptions); setIsInitialized(true); - console.log('[MembershipModalProvider] RevenueCat SDK 初始化成功'); + logger.info('[MembershipModalProvider] RevenueCat SDK 初始化成功'); } } catch (error) { - console.error('[MembershipModalProvider] RevenueCat SDK 初始化失败:', error); + logger.error('[MembershipModalProvider] RevenueCat SDK 初始化失败:', error); // 初始化失败时不阻止应用正常运行 } }; initializeRevenueCat(); - }, [isInitialized]); + }, [isInitialized, userProfile?.id]); + + // 监听用户登录状态变化,在用户登录后更新RevenueCat的用户标识 + useEffect(() => { + const updateRevenueCatUser = async () => { + if (isInitialized && userProfile?.id) { + try { + // 检查当前RevenueCat的用户标识 + const customerInfo = await Purchases.getCustomerInfo(); + const currentAppUserID = customerInfo.originalAppUserId; + + // 如果当前用户ID与RevenueCat中的用户ID不同,则更新 + if (currentAppUserID !== userProfile.id) { + console.log('[MembershipModalProvider] 更新RevenueCat用户标识:', userProfile.id); + await Purchases.logIn(userProfile.id); + } + } catch (error) { + console.error('[MembershipModalProvider] 更新RevenueCat用户标识失败:', error); + } + } + }; + + updateRevenueCatUser(); + }, [userProfile?.id, isInitialized]); const openMembershipModal = useCallback((options?: MembershipModalOptions) => { setPendingSuccessCallback(() => options?.onPurchaseSuccess); diff --git a/hooks/useVipService.ts b/hooks/useVipService.ts new file mode 100644 index 0000000..6360790 --- /dev/null +++ b/hooks/useVipService.ts @@ -0,0 +1,91 @@ +import { useAppSelector } from '@/hooks/redux'; +import { selectUserProfile } from '@/store/userSlice'; +import { useCallback } from 'react'; + +/** + * 增值服务检查结果 + */ +export interface VipServiceResult { + /** 是否可以使用服务 */ + canUseService: boolean; + /** 是否是 VIP 用户 */ + isVip: boolean; + /** 剩余免费使用次数 */ + remainingFreeUsage: number; + /** 最大免费使用次数 */ + maxFreeUsage: number; + /** 是否需要显示会员付费弹窗 */ + shouldShowMembershipModal: boolean; +} + +/** + * VIP 增值服务 Hook + * 用于检查用户是否可以使用增值服务 + */ +export function useVipService() { + const userProfile = useAppSelector(selectUserProfile); + + /** + * 检查用户是否可以使用增值服务 + * @returns 检查结果 + */ + const checkServiceAccess = useCallback((): VipServiceResult => { + const isVip = userProfile?.isVip ?? false; + const freeUsageCount = userProfile?.freeUsageCount ?? 0; + const maxUsageCount = userProfile?.maxUsageCount ?? 5; + + console.log('userProfile', userProfile); + + + // VIP 用户可以使用所有服务 + if (isVip) { + return { + canUseService: true, + isVip: true, + remainingFreeUsage: 0, // VIP 用户不关心免费次数 + maxFreeUsage: 0, + shouldShowMembershipModal: false, + }; + } + + // 计算剩余免费使用次数 + const canUseService = freeUsageCount > 0; + + return { + canUseService, + isVip: false, + remainingFreeUsage: freeUsageCount, + maxFreeUsage: maxUsageCount, + shouldShowMembershipModal: !canUseService, + }; + }, [userProfile]); + + /** + * 检查并处理服务访问 + * @param onAllowed 当允许使用时的回调函数 + * @param onBlocked 当阻止使用时的回调函数(可选) + * @returns 是否可以继续使用服务 + */ + const handleServiceAccess = useCallback(( + onAllowed: () => void, + onBlocked?: () => void + ): boolean => { + const result = checkServiceAccess(); + + if (result.canUseService) { + onAllowed(); + return true; + } else { + onBlocked?.(); + return false; + } + }, [checkServiceAccess]); + + return { + checkServiceAccess, + handleServiceAccess, + isVip: userProfile?.isVip ?? false, + freeUsageCount: userProfile?.freeUsageCount ?? 0, + maxUsageCount: userProfile?.maxUsageCount ?? 5, + }; +} \ No newline at end of file