feat(vip): 实现VIP服务权限控制和食物识别功能限制

- 添加VIP服务权限检查hook,支持免费使用次数限制
- 为食物识别功能添加登录验证和VIP权限检查
- 优化RevenueCat用户标识同步逻辑
- 修复会员购买状态检查的类型安全问题
- 为营养成分分析添加登录验证
This commit is contained in:
richarjiang
2025-10-29 09:44:30 +08:00
parent eaa7f7275c
commit fcf1be211f
5 changed files with 196 additions and 15 deletions

View File

@@ -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([]);

View File

@@ -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}
>

View File

@@ -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<string, string>).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: '没有找到购买记录',

View File

@@ -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);

91
hooks/useVipService.ts Normal file
View File

@@ -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,
};
}