feat(vip): 实现VIP服务权限控制和食物识别功能限制
- 添加VIP服务权限检查hook,支持免费使用次数限制 - 为食物识别功能添加登录验证和VIP权限检查 - 优化RevenueCat用户标识同步逻辑 - 修复会员购买状态检查的类型安全问题 - 为营养成分分析添加登录验证
This commit is contained in:
@@ -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([]);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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: '没有找到购买记录',
|
||||
|
||||
@@ -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
91
hooks/useVipService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user