更新ios
This commit is contained in:
@@ -4,5 +4,9 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.in-app-payments</key>
|
||||
<array>
|
||||
<string>merchant.com.valuefrontier.meagent</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -62,6 +62,9 @@ import MemberList from "../src/screens/Community/MemberList";
|
||||
// 新个人中心页面
|
||||
import { ProfileScreen as NewProfileScreen } from "../src/screens/Profile";
|
||||
|
||||
// 订阅管理页面
|
||||
import { SubscriptionScreen } from "../src/screens/Subscription";
|
||||
|
||||
// 认证页面
|
||||
import { LoginScreen } from "../src/screens/Auth";
|
||||
|
||||
@@ -510,6 +513,13 @@ function NewProfileStack(props) {
|
||||
cardStyle: { backgroundColor: "#0A0A0F" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Subscription"
|
||||
component={SubscriptionScreen}
|
||||
options={{
|
||||
cardStyle: { backgroundColor: "#0A0A0F" },
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,6 +354,8 @@ const ProfileScreen = () => {
|
||||
const handleMenuItemPress = useCallback((item) => {
|
||||
if (item.route === 'WatchlistDrawer') {
|
||||
navigation.navigate('WatchlistDrawer');
|
||||
} else if (item.route === 'Subscription') {
|
||||
navigation.navigate('Subscription');
|
||||
} else if (item.route === 'Settings') {
|
||||
navigation.navigate('SettingsDrawer');
|
||||
} else if (item.route === 'About') {
|
||||
|
||||
611
MeAgent/src/screens/Subscription/SubscriptionScreen.js
Normal file
611
MeAgent/src/screens/Subscription/SubscriptionScreen.js
Normal file
@@ -0,0 +1,611 @@
|
||||
/**
|
||||
* 订阅管理页面
|
||||
* iOS 苹果内购订阅功能
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Alert,
|
||||
Platform,
|
||||
Linking,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
Pressable,
|
||||
Spinner,
|
||||
Button,
|
||||
Badge,
|
||||
Divider,
|
||||
} from 'native-base';
|
||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { IAP_PRODUCTS, getProductByPlanAndCycle, iapService } from '../../services/iapService';
|
||||
|
||||
// 订阅计划配置
|
||||
const SUBSCRIPTION_PLANS = [
|
||||
{
|
||||
name: 'pro',
|
||||
displayName: 'Pro 专业版',
|
||||
icon: 'diamond-outline',
|
||||
color: '#7C3AED',
|
||||
gradientColors: ['#7C3AED', '#A855F7'],
|
||||
description: '专业投资者的首选',
|
||||
features: [
|
||||
'事件关联股票深度分析',
|
||||
'历史事件智能对比复盘',
|
||||
'事件概念关联与挖掘',
|
||||
'概念板块个股追踪',
|
||||
'概念深度研报与解读',
|
||||
'个股异动实时预警',
|
||||
],
|
||||
pricingOptions: [
|
||||
{ cycleKey: 'monthly', label: '月付', months: 1, price: 299, discount: 0 },
|
||||
{ cycleKey: 'quarterly', label: '季付', months: 3, price: 799, originalPrice: 897, discount: 11 },
|
||||
{ cycleKey: 'semiannual', label: '半年付', months: 6, price: 1499, originalPrice: 1794, discount: 16 },
|
||||
{ cycleKey: 'yearly', label: '年付', months: 12, price: 2699, originalPrice: 3588, discount: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'max',
|
||||
displayName: 'Max 旗舰版',
|
||||
icon: 'crown-outline',
|
||||
color: '#D4AF37',
|
||||
gradientColors: ['#D4AF37', '#F5D85A'],
|
||||
description: '重度用户的极致体验',
|
||||
popular: true,
|
||||
features: [
|
||||
'包含Pro版全部功能',
|
||||
'事件传导链路智能分析',
|
||||
'概念演变时间轴追溯',
|
||||
'个股全方位深度研究',
|
||||
'价小前投研助手无限使用',
|
||||
'新功能优先体验权',
|
||||
'专属客服一对一服务',
|
||||
],
|
||||
pricingOptions: [
|
||||
{ cycleKey: 'monthly', label: '月付', months: 1, price: 599, discount: 0 },
|
||||
{ cycleKey: 'quarterly', label: '季付', months: 3, price: 1599, originalPrice: 1797, discount: 11 },
|
||||
{ cycleKey: 'semiannual', label: '半年付', months: 6, price: 2999, originalPrice: 3594, discount: 17 },
|
||||
{ cycleKey: 'yearly', label: '年付', months: 12, price: 5399, originalPrice: 7188, discount: 25 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 周期选择按钮组件
|
||||
const CycleSelector = ({ cycles, selectedCycle, onSelect }) => {
|
||||
return (
|
||||
<HStack space={2} flexWrap="wrap" justifyContent="center" mb={4}>
|
||||
{cycles.map((cycle) => (
|
||||
<Pressable
|
||||
key={cycle.cycleKey}
|
||||
onPress={() => onSelect(cycle.cycleKey)}
|
||||
mb={2}
|
||||
>
|
||||
<Box
|
||||
position="relative"
|
||||
px={4}
|
||||
py={2}
|
||||
bg={selectedCycle === cycle.cycleKey ? 'rgba(212, 175, 55, 0.2)' : 'rgba(255, 255, 255, 0.05)'}
|
||||
borderWidth={1}
|
||||
borderColor={selectedCycle === cycle.cycleKey ? '#D4AF37' : 'rgba(255, 255, 255, 0.1)'}
|
||||
borderRadius={10}
|
||||
>
|
||||
<Text
|
||||
color={selectedCycle === cycle.cycleKey ? '#D4AF37' : 'white'}
|
||||
fontWeight={selectedCycle === cycle.cycleKey ? 'bold' : 'normal'}
|
||||
fontSize={14}
|
||||
>
|
||||
{cycle.label}
|
||||
</Text>
|
||||
{cycle.discount > 0 && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top={-8}
|
||||
right={-8}
|
||||
bg="#EF4444"
|
||||
borderRadius={8}
|
||||
px={1}
|
||||
py={0.5}
|
||||
>
|
||||
<Text color="white" fontSize={9} fontWeight="bold">
|
||||
省{cycle.discount}%
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Pressable>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 价格显示组件
|
||||
const PriceDisplay = ({ plan, selectedCycle }) => {
|
||||
const option = plan.pricingOptions.find(o => o.cycleKey === selectedCycle);
|
||||
if (!option) return null;
|
||||
|
||||
return (
|
||||
<VStack alignItems="center" space={1} mb={4}>
|
||||
<HStack alignItems="baseline" space={1}>
|
||||
<Text color="#D4AF37" fontSize={16}>¥</Text>
|
||||
<Text color="#D4AF37" fontSize={36} fontWeight="bold">{option.price}</Text>
|
||||
<Text color="gray.400" fontSize={14}>/{option.label}</Text>
|
||||
</HStack>
|
||||
{option.originalPrice && (
|
||||
<HStack alignItems="center" space={2}>
|
||||
<Text color="gray.500" fontSize={12} strikeThrough>
|
||||
原价 ¥{option.originalPrice}
|
||||
</Text>
|
||||
<Text color="#EF4444" fontSize={12} fontWeight="bold">
|
||||
立省 ¥{option.originalPrice - option.price}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<Text color="gray.500" fontSize={11}>
|
||||
相当于 ¥{(option.price / option.months).toFixed(0)}/月
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 功能列表组件
|
||||
const FeatureList = ({ features, color }) => {
|
||||
return (
|
||||
<VStack space={2} mb={4}>
|
||||
{features.map((feature, index) => (
|
||||
<HStack key={index} alignItems="center" space={2}>
|
||||
<Icon as={Ionicons} name="checkmark-circle" size="sm" color={color} />
|
||||
<Text color="rgba(255, 255, 255, 0.85)" fontSize={13}>
|
||||
{feature}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 订阅卡片组件
|
||||
const PlanCard = ({ plan, selectedCycle, onSelectCycle, onSubscribe, isCurrentPlan, loading }) => {
|
||||
const currentOption = plan.pricingOptions.find(o => o.cycleKey === selectedCycle);
|
||||
|
||||
return (
|
||||
<Box
|
||||
mx={4}
|
||||
mb={4}
|
||||
borderRadius={20}
|
||||
overflow="hidden"
|
||||
borderWidth={1}
|
||||
borderColor={plan.popular ? 'rgba(212, 175, 55, 0.5)' : 'rgba(255, 255, 255, 0.1)'}
|
||||
>
|
||||
{/* 热门标签 */}
|
||||
{plan.popular && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
bg="linear-gradient(135deg, #D4AF37, #F5D85A)"
|
||||
px={3}
|
||||
py={1}
|
||||
borderBottomLeftRadius={10}
|
||||
zIndex={1}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#D4AF37', '#F5D85A']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.popularBadge}
|
||||
>
|
||||
<Text color="#000" fontSize={11} fontWeight="bold">
|
||||
最受欢迎
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 卡片标题区域 */}
|
||||
<LinearGradient
|
||||
colors={plan.gradientColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.cardHeader}
|
||||
>
|
||||
<HStack alignItems="center" space={3}>
|
||||
<Icon as={MaterialCommunityIcons} name={plan.icon} size="lg" color="white" />
|
||||
<VStack>
|
||||
<Text color="white" fontSize={18} fontWeight="bold">
|
||||
{plan.displayName}
|
||||
</Text>
|
||||
<Text color="rgba(255, 255, 255, 0.8)" fontSize={12}>
|
||||
{plan.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</LinearGradient>
|
||||
|
||||
{/* 卡片内容 */}
|
||||
<Box bg="rgba(255, 255, 255, 0.03)" p={4}>
|
||||
{/* 周期选择 */}
|
||||
<CycleSelector
|
||||
cycles={plan.pricingOptions}
|
||||
selectedCycle={selectedCycle}
|
||||
onSelect={onSelectCycle}
|
||||
/>
|
||||
|
||||
{/* 价格显示 */}
|
||||
<PriceDisplay plan={plan} selectedCycle={selectedCycle} />
|
||||
|
||||
{/* 功能列表 */}
|
||||
<FeatureList features={plan.features} color={plan.color} />
|
||||
|
||||
{/* 订阅按钮 */}
|
||||
<Pressable
|
||||
onPress={() => onSubscribe(plan, selectedCycle)}
|
||||
disabled={loading}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={isCurrentPlan ? ['#4B5563', '#6B7280'] : plan.gradientColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.subscribeButton}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner color="white" size="sm" />
|
||||
) : (
|
||||
<Text color="white" fontSize={16} fontWeight="bold">
|
||||
{isCurrentPlan ? '续费' : `订阅${plan.displayName}`}
|
||||
</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
|
||||
{/* 自动续费说明 */}
|
||||
<Text color="gray.500" fontSize={10} textAlign="center" mt={2}>
|
||||
自动续费,可随时取消
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 当前订阅状态卡片
|
||||
const CurrentSubscriptionCard = ({ subscription, onManage }) => {
|
||||
if (!subscription || !subscription.is_active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getPlanInfo = () => {
|
||||
if (subscription.type === 'max') {
|
||||
return { name: 'Max 旗舰版', color: '#D4AF37', icon: 'crown-outline' };
|
||||
}
|
||||
if (subscription.type === 'pro') {
|
||||
return { name: 'Pro 专业版', color: '#7C3AED', icon: 'diamond-outline' };
|
||||
}
|
||||
return { name: '会员', color: '#3B82F6', icon: 'star-outline' };
|
||||
};
|
||||
|
||||
const planInfo = getPlanInfo();
|
||||
|
||||
return (
|
||||
<Box
|
||||
mx={4}
|
||||
mb={4}
|
||||
p={4}
|
||||
bg="rgba(212, 175, 55, 0.1)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(212, 175, 55, 0.3)"
|
||||
borderRadius={16}
|
||||
>
|
||||
<HStack alignItems="center" justifyContent="space-between">
|
||||
<HStack alignItems="center" space={3}>
|
||||
<Box
|
||||
bg={`${planInfo.color}20`}
|
||||
p={2}
|
||||
borderRadius={10}
|
||||
>
|
||||
<Icon as={MaterialCommunityIcons} name={planInfo.icon} size="md" color={planInfo.color} />
|
||||
</Box>
|
||||
<VStack>
|
||||
<HStack alignItems="center" space={2}>
|
||||
<Text color="white" fontSize={16} fontWeight="bold">
|
||||
{planInfo.name}
|
||||
</Text>
|
||||
<Badge bg="rgba(76, 175, 80, 0.2)" borderRadius={6}>
|
||||
<Text color="#4CAF50" fontSize={10}>使用中</Text>
|
||||
</Badge>
|
||||
</HStack>
|
||||
{subscription.end_date && (
|
||||
<Text color="gray.400" fontSize={12}>
|
||||
到期时间: {new Date(subscription.end_date).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Pressable onPress={onManage}>
|
||||
<Text color="#D4AF37" fontSize={13}>管理</Text>
|
||||
</Pressable>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 主页面组件
|
||||
const SubscriptionScreen = () => {
|
||||
const navigation = useNavigation();
|
||||
const { user, subscription, refreshUser } = useAuth();
|
||||
|
||||
const [selectedCycles, setSelectedCycles] = useState({
|
||||
pro: 'yearly',
|
||||
max: 'yearly',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingPlan, setLoadingPlan] = useState(null);
|
||||
|
||||
// 初始化 IAP 服务
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'ios') {
|
||||
iapService.initialize();
|
||||
}
|
||||
|
||||
return () => {
|
||||
iapService.cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理周期选择
|
||||
const handleCycleSelect = useCallback((planName, cycle) => {
|
||||
setSelectedCycles(prev => ({
|
||||
...prev,
|
||||
[planName]: cycle,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 处理订阅
|
||||
const handleSubscribe = useCallback(async (plan, cycle) => {
|
||||
if (!user) {
|
||||
Alert.alert(
|
||||
'请先登录',
|
||||
'登录后即可订阅会员服务',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '去登录',
|
||||
onPress: () => navigation.getParent()?.navigate('Login'),
|
||||
},
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS !== 'ios') {
|
||||
Alert.alert('提示', '苹果支付仅支持 iOS 设备');
|
||||
return;
|
||||
}
|
||||
|
||||
const productInfo = getProductByPlanAndCycle(plan.name, cycle);
|
||||
if (!productInfo) {
|
||||
Alert.alert('错误', '无效的订阅选项');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingPlan(plan.name);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 发起购买
|
||||
const purchase = await iapService.purchaseProduct(productInfo.productId);
|
||||
|
||||
// 验证收据并激活订阅
|
||||
if (purchase && purchase.transactionReceipt) {
|
||||
const result = await iapService.verifyAndActivateSubscription(
|
||||
purchase.transactionReceipt,
|
||||
productInfo.productId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// 完成交易(传入完整的 purchase 对象)
|
||||
await iapService.finishPurchase(purchase);
|
||||
|
||||
// 刷新用户信息
|
||||
await refreshUser();
|
||||
|
||||
Alert.alert(
|
||||
'订阅成功',
|
||||
`您已成功订阅${plan.displayName}!`,
|
||||
[{ text: '好的', onPress: () => navigation.goBack() }]
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SubscriptionScreen] 订阅失败:', error);
|
||||
Alert.alert(
|
||||
'订阅失败',
|
||||
error.message || '请稍后重试',
|
||||
[{ text: '确定' }]
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingPlan(null);
|
||||
}
|
||||
}, [user, navigation, refreshUser]);
|
||||
|
||||
// 管理订阅(打开苹果订阅管理页面)
|
||||
const handleManageSubscription = useCallback(() => {
|
||||
// iOS 订阅管理页面 URL
|
||||
Linking.openURL('https://apps.apple.com/account/subscriptions');
|
||||
}, []);
|
||||
|
||||
// 恢复购买
|
||||
const handleRestorePurchases = useCallback(async () => {
|
||||
if (Platform.OS !== 'ios') {
|
||||
Alert.alert('提示', '恢复购买仅支持 iOS 设备');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const purchases = await iapService.restorePurchases();
|
||||
|
||||
if (purchases && purchases.length > 0) {
|
||||
// 验证最新的购买记录
|
||||
const latestPurchase = purchases[0];
|
||||
if (latestPurchase.transactionReceipt) {
|
||||
const productInfo = Object.values(IAP_PRODUCTS).find(
|
||||
p => p.productId === latestPurchase.productId
|
||||
);
|
||||
|
||||
if (productInfo) {
|
||||
await iapService.verifyAndActivateSubscription(
|
||||
latestPurchase.transactionReceipt,
|
||||
latestPurchase.productId
|
||||
);
|
||||
|
||||
await refreshUser();
|
||||
Alert.alert('恢复成功', '您的订阅已恢复');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Alert.alert('提示', '没有找到可恢复的购买记录');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SubscriptionScreen] 恢复购买失败:', error);
|
||||
Alert.alert('恢复失败', error.message || '请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshUser]);
|
||||
|
||||
return (
|
||||
<Box flex={1} bg="#0A0A0F">
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* 背景装饰 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={-100}
|
||||
left={-100}
|
||||
w={300}
|
||||
h={300}
|
||||
bg="rgba(212, 175, 55, 0.08)"
|
||||
borderRadius={150}
|
||||
style={{ transform: [{ rotate: '45deg' }] }}
|
||||
/>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* 标题栏 */}
|
||||
<HStack px={4} py={3} alignItems="center">
|
||||
<Pressable onPress={() => navigation.goBack()} hitSlop={10}>
|
||||
<Icon as={Ionicons} name="chevron-back" size="md" color="white" />
|
||||
</Pressable>
|
||||
<Text flex={1} color="white" fontSize={18} fontWeight="bold" textAlign="center">
|
||||
订阅管理
|
||||
</Text>
|
||||
<Box w={6} /> {/* 占位,保持标题居中 */}
|
||||
</HStack>
|
||||
|
||||
{/* 当前订阅状态 */}
|
||||
<CurrentSubscriptionCard
|
||||
subscription={subscription}
|
||||
onManage={handleManageSubscription}
|
||||
/>
|
||||
|
||||
{/* 标题 */}
|
||||
<VStack alignItems="center" px={4} mb={6}>
|
||||
<Text color="#D4AF37" fontSize={12} fontWeight="medium" letterSpacing={1} mb={1}>
|
||||
订阅方案
|
||||
</Text>
|
||||
<Text color="white" fontSize={24} fontWeight="bold" textAlign="center">
|
||||
开启智能投资之旅
|
||||
</Text>
|
||||
<Text color="gray.400" fontSize={13} textAlign="center" mt={2}>
|
||||
选择适合您的会员计划,解锁全部高级功能
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* 订阅卡片 */}
|
||||
{SUBSCRIPTION_PLANS.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.name}
|
||||
plan={plan}
|
||||
selectedCycle={selectedCycles[plan.name]}
|
||||
onSelectCycle={(cycle) => handleCycleSelect(plan.name, cycle)}
|
||||
onSubscribe={handleSubscribe}
|
||||
isCurrentPlan={subscription?.type === plan.name && subscription?.is_active}
|
||||
loading={loading && loadingPlan === plan.name}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 恢复购买按钮 */}
|
||||
<Pressable onPress={handleRestorePurchases} disabled={loading}>
|
||||
<HStack justifyContent="center" alignItems="center" py={4}>
|
||||
<Icon as={Ionicons} name="refresh-outline" size="sm" color="#D4AF37" mr={2} />
|
||||
<Text color="#D4AF37" fontSize={14}>
|
||||
恢复购买
|
||||
</Text>
|
||||
</HStack>
|
||||
</Pressable>
|
||||
|
||||
{/* 说明文字 */}
|
||||
<VStack px={6} pb={8}>
|
||||
<Text color="gray.500" fontSize={11} textAlign="center" mb={2}>
|
||||
订阅将通过您的 Apple ID 账户付款
|
||||
</Text>
|
||||
<Text color="gray.500" fontSize={11} textAlign="center" mb={2}>
|
||||
除非在当前订阅期结束前至少 24 小时关闭自动续订,否则将自动续订
|
||||
</Text>
|
||||
<Text color="gray.500" fontSize={11} textAlign="center">
|
||||
您可以在 App Store 账户设置中管理和取消订阅
|
||||
</Text>
|
||||
|
||||
<HStack justifyContent="center" space={4} mt={4}>
|
||||
<Pressable onPress={() => Linking.openURL('https://valuefrontier.cn/htmls/pro-member-agreement.html')}>
|
||||
<Text color="gray.400" fontSize={11} underline>
|
||||
服务协议
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => Linking.openURL('https://valuefrontier.cn/privacy')}>
|
||||
<Text color="gray.400" fontSize={11} underline>
|
||||
隐私政策
|
||||
</Text>
|
||||
</Pressable>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 底部间距 */}
|
||||
<Box height={50} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
cardHeader: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
popularBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderBottomLeftRadius: 10,
|
||||
},
|
||||
subscribeButton: {
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default SubscriptionScreen;
|
||||
2
MeAgent/src/screens/Subscription/index.js
Normal file
2
MeAgent/src/screens/Subscription/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as SubscriptionScreen } from './SubscriptionScreen';
|
||||
export { default } from './SubscriptionScreen';
|
||||
@@ -13,6 +13,49 @@ export const API_BASE = 'https://valuefrontier-1308417363.cos-website.ap-shangha
|
||||
// Token 存储键名(与 authService 保持一致)
|
||||
const ACCESS_TOKEN_KEY = '@auth_access_token';
|
||||
|
||||
// 🍪 Cookie 存储键名(用于手动管理 Cookie)
|
||||
const SESSION_COOKIE_KEY = '@session_cookie';
|
||||
|
||||
/**
|
||||
* 从响应头中提取 Set-Cookie
|
||||
*/
|
||||
const extractSetCookie = (response) => {
|
||||
// React Native 的 fetch 可能无法直接获取 Set-Cookie
|
||||
// 但我们尝试获取
|
||||
const setCookie = response.headers.get('set-cookie');
|
||||
if (setCookie) {
|
||||
console.log('[API] 收到 Set-Cookie:', setCookie.substring(0, 50) + '...');
|
||||
return setCookie;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存 Cookie 到 AsyncStorage
|
||||
*/
|
||||
const saveCookie = async (cookie) => {
|
||||
if (cookie) {
|
||||
try {
|
||||
await AsyncStorage.setItem(SESSION_COOKIE_KEY, cookie);
|
||||
console.log('[API] Cookie 已保存');
|
||||
} catch (error) {
|
||||
console.error('[API] 保存 Cookie 失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 AsyncStorage 获取 Cookie
|
||||
*/
|
||||
const getCookie = async () => {
|
||||
try {
|
||||
return await AsyncStorage.getItem(SESSION_COOKIE_KEY);
|
||||
} catch (error) {
|
||||
console.error('[API] 读取 Cookie 失败:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用 API 请求函数
|
||||
* @param {string} url - API 路径
|
||||
@@ -29,6 +72,9 @@ export const apiRequest = async (url, options = {}) => {
|
||||
// 获取存储的 access_token
|
||||
const accessToken = await AsyncStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
|
||||
// 🍪 获取存储的 Cookie
|
||||
const storedCookie = await getCookie();
|
||||
|
||||
// 构建请求头
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -40,6 +86,12 @@ export const apiRequest = async (url, options = {}) => {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
// 🍪 手动添加 Cookie header(React Native 真机可能需要)
|
||||
if (storedCookie) {
|
||||
headers['Cookie'] = storedCookie;
|
||||
console.log('[API] 添加 Cookie header');
|
||||
}
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
...options,
|
||||
headers,
|
||||
@@ -47,6 +99,12 @@ export const apiRequest = async (url, options = {}) => {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// 🍪 尝试从响应中提取并保存 Cookie
|
||||
const setCookie = extractSetCookie(response);
|
||||
if (setCookie) {
|
||||
await saveCookie(setCookie);
|
||||
}
|
||||
|
||||
// 处理 403 权限不足的情况
|
||||
if (response.status === 403) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
@@ -75,4 +133,16 @@ export const apiRequest = async (url, options = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除存储的 Cookie(退出登录时调用)
|
||||
*/
|
||||
export const clearSessionCookie = async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(SESSION_COOKIE_KEY);
|
||||
console.log('[API] Cookie 已清除');
|
||||
} catch (error) {
|
||||
console.error('[API] 清除 Cookie 失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export default apiRequest;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { apiRequest } from './api';
|
||||
import { apiRequest, clearSessionCookie } from './api';
|
||||
|
||||
// 存储键名
|
||||
const STORAGE_KEYS = {
|
||||
@@ -89,18 +89,27 @@ export const authService = {
|
||||
*/
|
||||
getCurrentUser: async (clearOnFail = false) => {
|
||||
try {
|
||||
const response = await apiRequest('/api/account/user-info');
|
||||
// ✅ 修复:使用正确的 API 端点 /api/auth/session
|
||||
const response = await apiRequest('/api/auth/session');
|
||||
|
||||
if (response.success && response.user) {
|
||||
// 🔍 调试日志:打印完整响应
|
||||
console.log('[AuthService] /api/auth/session 响应:', JSON.stringify(response, null, 2));
|
||||
|
||||
if (response.success && response.isAuthenticated && response.user) {
|
||||
// 更新本地存储
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.USER_INFO, JSON.stringify(response.user));
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.IS_LOGGED_IN, 'true');
|
||||
|
||||
// 🔍 调试日志:打印用户订阅信息
|
||||
console.log('[AuthService] 用户订阅类型:', response.user.subscription_type);
|
||||
console.log('[AuthService] 用户订阅状态:', response.user.subscription_status);
|
||||
|
||||
return response.user;
|
||||
}
|
||||
|
||||
// 如果返回 401/403,说明 token 无效
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.warn('[AuthService] Token 无效,需要重新登录');
|
||||
// 未认证或返回 401/403
|
||||
if (!response.isAuthenticated || response.status === 401 || response.status === 403) {
|
||||
console.warn('[AuthService] 未认证或 Token 无效,需要重新登录');
|
||||
if (clearOnFail) {
|
||||
await authService.clearLocalAuth();
|
||||
}
|
||||
@@ -142,6 +151,8 @@ export const authService = {
|
||||
await AsyncStorage.removeItem(STORAGE_KEYS.USER_INFO);
|
||||
await AsyncStorage.removeItem(STORAGE_KEYS.IS_LOGGED_IN);
|
||||
await AsyncStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
|
||||
// 🍪 同时清除 Cookie
|
||||
await clearSessionCookie();
|
||||
} catch (error) {
|
||||
console.error('[AuthService] 清除本地状态失败:', error);
|
||||
}
|
||||
|
||||
441
MeAgent/src/services/iapService.js
Normal file
441
MeAgent/src/services/iapService.js
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* 苹果内购服务 (In-App Purchase Service)
|
||||
* 处理 iOS 订阅购买逻辑
|
||||
*
|
||||
* 使用 react-native-iap 库实现苹果支付
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
initConnection,
|
||||
endConnection,
|
||||
getProducts,
|
||||
getSubscriptions,
|
||||
requestSubscription,
|
||||
getAvailablePurchases,
|
||||
finishTransaction,
|
||||
purchaseUpdatedListener,
|
||||
purchaseErrorListener,
|
||||
flushFailedPurchasesCachedAsPendingAndroid,
|
||||
} from 'react-native-iap';
|
||||
import { API_BASE_URL } from './api';
|
||||
|
||||
// 苹果内购产品 ID 配置
|
||||
// 格式: com.valuefrontier.meagent.{plan}_{cycle}
|
||||
export const IAP_PRODUCTS = {
|
||||
// Pro 专业版
|
||||
pro_monthly: {
|
||||
productId: 'com.valuefrontier.meagent.pro_monthly',
|
||||
plan: 'pro',
|
||||
cycle: 'monthly',
|
||||
months: 1,
|
||||
price: 299, // 人民币价格(供显示,实际以苹果返回为准)
|
||||
},
|
||||
pro_quarterly: {
|
||||
productId: 'com.valuefrontier.meagent.pro_quarterly',
|
||||
plan: 'pro',
|
||||
cycle: 'quarterly',
|
||||
months: 3,
|
||||
price: 799,
|
||||
},
|
||||
pro_semiannual: {
|
||||
productId: 'com.valuefrontier.meagent.pro_semiannual',
|
||||
plan: 'pro',
|
||||
cycle: 'semiannual',
|
||||
months: 6,
|
||||
price: 1499,
|
||||
},
|
||||
pro_yearly: {
|
||||
productId: 'com.valuefrontier.meagent.pro_yearly',
|
||||
plan: 'pro',
|
||||
cycle: 'yearly',
|
||||
months: 12,
|
||||
price: 2699,
|
||||
},
|
||||
// Max 旗舰版
|
||||
max_monthly: {
|
||||
productId: 'com.valuefrontier.meagent.max_monthly',
|
||||
plan: 'max',
|
||||
cycle: 'monthly',
|
||||
months: 1,
|
||||
price: 599,
|
||||
},
|
||||
max_quarterly: {
|
||||
productId: 'com.valuefrontier.meagent.max_quarterly',
|
||||
plan: 'max',
|
||||
cycle: 'quarterly',
|
||||
months: 3,
|
||||
price: 1599,
|
||||
},
|
||||
max_semiannual: {
|
||||
productId: 'com.valuefrontier.meagent.max_semiannual',
|
||||
plan: 'max',
|
||||
cycle: 'semiannual',
|
||||
months: 6,
|
||||
price: 2999,
|
||||
},
|
||||
max_yearly: {
|
||||
productId: 'com.valuefrontier.meagent.max_yearly',
|
||||
plan: 'max',
|
||||
cycle: 'yearly',
|
||||
months: 12,
|
||||
price: 5399,
|
||||
},
|
||||
};
|
||||
|
||||
// 获取所有产品 ID 列表
|
||||
export const getAllProductIds = () => {
|
||||
return Object.values(IAP_PRODUCTS).map(p => p.productId);
|
||||
};
|
||||
|
||||
// 根据计划和周期获取产品信息
|
||||
export const getProductByPlanAndCycle = (plan, cycle) => {
|
||||
const key = `${plan}_${cycle}`;
|
||||
return IAP_PRODUCTS[key] || null;
|
||||
};
|
||||
|
||||
// 根据产品 ID 获取产品信息
|
||||
export const getProductById = (productId) => {
|
||||
return Object.values(IAP_PRODUCTS).find(p => p.productId === productId) || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* IAP 服务类
|
||||
* 使用 react-native-iap 实现苹果内购功能
|
||||
*/
|
||||
class IAPService {
|
||||
constructor() {
|
||||
this.isInitialized = false;
|
||||
this.products = [];
|
||||
this.subscriptions = [];
|
||||
this.purchaseUpdateSubscription = null;
|
||||
this.purchaseErrorSubscription = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 IAP 服务
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async initialize() {
|
||||
if (Platform.OS !== 'ios') {
|
||||
console.log('[IAPService] 非 iOS 平台,跳过初始化');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isInitialized) {
|
||||
console.log('[IAPService] 已经初始化,跳过');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 初始化连接
|
||||
const result = await initConnection();
|
||||
console.log('[IAPService] IAP 连接初始化成功:', result);
|
||||
|
||||
// 获取订阅产品信息
|
||||
const productIds = getAllProductIds();
|
||||
console.log('[IAPService] 获取产品列表:', productIds);
|
||||
|
||||
try {
|
||||
// 对于订阅类产品,使用 getSubscriptions
|
||||
this.subscriptions = await getSubscriptions({ skus: productIds });
|
||||
console.log('[IAPService] 获取到订阅产品:', this.subscriptions.length);
|
||||
} catch (productError) {
|
||||
console.warn('[IAPService] 获取产品信息失败(可能产品未在 App Store Connect 配置):', productError);
|
||||
// 继续初始化,不阻止流程
|
||||
}
|
||||
|
||||
// 设置购买监听器
|
||||
this.setupPurchaseListeners();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('[IAPService] IAP 服务初始化完成');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[IAPService] 初始化失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置购买事件监听器
|
||||
*/
|
||||
setupPurchaseListeners() {
|
||||
// 移除旧的监听器
|
||||
if (this.purchaseUpdateSubscription) {
|
||||
this.purchaseUpdateSubscription.remove();
|
||||
}
|
||||
if (this.purchaseErrorSubscription) {
|
||||
this.purchaseErrorSubscription.remove();
|
||||
}
|
||||
|
||||
// 购买成功/更新监听器
|
||||
this.purchaseUpdateSubscription = purchaseUpdatedListener(async (purchase) => {
|
||||
console.log('[IAPService] 购买更新:', purchase);
|
||||
|
||||
if (purchase.transactionReceipt) {
|
||||
try {
|
||||
// 验证收据
|
||||
const verifyResult = await this.verifyAndActivateSubscription(
|
||||
purchase.transactionReceipt,
|
||||
purchase.productId
|
||||
);
|
||||
|
||||
if (verifyResult.success) {
|
||||
// 完成交易
|
||||
await finishTransaction({ purchase, isConsumable: false });
|
||||
console.log('[IAPService] 交易完成');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[IAPService] 处理购买失败:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 购买错误监听器
|
||||
this.purchaseErrorSubscription = purchaseErrorListener((error) => {
|
||||
console.error('[IAPService] 购买错误:', error);
|
||||
});
|
||||
|
||||
console.log('[IAPService] 购买监听器已设置');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用产品列表(从苹果服务器获取最新价格)
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async getAvailableProducts() {
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// 如果成功获取了苹果的产品信息,返回真实数据
|
||||
if (this.subscriptions && this.subscriptions.length > 0) {
|
||||
return this.subscriptions.map(subscription => {
|
||||
const localProduct = getProductById(subscription.productId);
|
||||
return {
|
||||
...localProduct,
|
||||
productId: subscription.productId,
|
||||
localizedPrice: subscription.localizedPrice,
|
||||
price: subscription.price,
|
||||
currency: subscription.currency,
|
||||
title: subscription.title,
|
||||
description: subscription.description,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 否则返回本地配置的产品信息(用于测试或产品未配置时)
|
||||
console.log('[IAPService] 使用本地产品配置');
|
||||
return Object.values(IAP_PRODUCTS).map(product => ({
|
||||
...product,
|
||||
localizedPrice: `¥${product.price}`,
|
||||
currency: 'CNY',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起订阅购买请求
|
||||
* @param {string} productId - 产品 ID
|
||||
* @returns {Promise<Object>} 购买结果
|
||||
*/
|
||||
async purchaseProduct(productId) {
|
||||
if (Platform.OS !== 'ios') {
|
||||
throw new Error('苹果支付仅支持 iOS 设备');
|
||||
}
|
||||
|
||||
const productInfo = getProductById(productId);
|
||||
if (!productInfo) {
|
||||
throw new Error('无效的产品 ID');
|
||||
}
|
||||
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[IAPService] 发起订阅购买:', productId);
|
||||
|
||||
// 对于订阅类产品,使用 requestSubscription
|
||||
const purchase = await requestSubscription({
|
||||
sku: productId,
|
||||
andDangerouslyFinishTransactionAutomaticallyIOS: false, // 手动完成交易
|
||||
});
|
||||
|
||||
console.log('[IAPService] 购买请求已发起:', purchase);
|
||||
return purchase;
|
||||
} catch (error) {
|
||||
console.error('[IAPService] 购买失败:', error);
|
||||
|
||||
// 处理用户取消的情况
|
||||
if (error.code === 'E_USER_CANCELLED') {
|
||||
throw new Error('您取消了购买');
|
||||
}
|
||||
|
||||
// 处理其他错误
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证收据并激活订阅
|
||||
* @param {string} receiptData - 苹果收据数据
|
||||
* @param {string} productId - 产品 ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async verifyAndActivateSubscription(receiptData, productId) {
|
||||
const productInfo = getProductById(productId);
|
||||
if (!productInfo) {
|
||||
throw new Error('无效的产品 ID');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[IAPService] 发送收据到后端验证...');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/payment/apple/verify-receipt`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
receipt_data: receiptData,
|
||||
product_id: productId,
|
||||
plan_name: productInfo.plan,
|
||||
billing_cycle: productInfo.cycle,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '收据验证失败');
|
||||
}
|
||||
|
||||
console.log('[IAPService] 收据验证成功:', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('[IAPService] 收据验证失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复购买(用于重新安装后恢复订阅)
|
||||
* @returns {Promise<Array>} 恢复的购买记录
|
||||
*/
|
||||
async restorePurchases() {
|
||||
if (Platform.OS !== 'ios') {
|
||||
throw new Error('恢复购买仅支持 iOS 设备');
|
||||
}
|
||||
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[IAPService] 开始恢复购买...');
|
||||
|
||||
const purchases = await getAvailablePurchases();
|
||||
console.log('[IAPService] 找到购买记录:', purchases.length);
|
||||
|
||||
// 筛选出我们的产品
|
||||
const ourProductIds = getAllProductIds();
|
||||
const ourPurchases = purchases.filter(p => ourProductIds.includes(p.productId));
|
||||
|
||||
console.log('[IAPService] 匹配的购买记录:', ourPurchases.length);
|
||||
|
||||
// 如果有购买记录,验证最新的一条
|
||||
if (ourPurchases.length > 0) {
|
||||
// 按购买时间排序,取最新的
|
||||
ourPurchases.sort((a, b) => {
|
||||
const timeA = a.transactionDate ? new Date(a.transactionDate).getTime() : 0;
|
||||
const timeB = b.transactionDate ? new Date(b.transactionDate).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
const latestPurchase = ourPurchases[0];
|
||||
|
||||
if (latestPurchase.transactionReceipt) {
|
||||
// 向后端验证并激活
|
||||
await this.verifyAndActivateSubscription(
|
||||
latestPurchase.transactionReceipt,
|
||||
latestPurchase.productId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ourPurchases;
|
||||
} catch (error) {
|
||||
console.error('[IAPService] 恢复购买失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成交易(确认购买已处理)
|
||||
* @param {Object} purchase - 购买对象
|
||||
*/
|
||||
async finishPurchase(purchase) {
|
||||
try {
|
||||
await finishTransaction({ purchase, isConsumable: false });
|
||||
console.log('[IAPService] 交易已完成:', purchase.transactionId);
|
||||
} catch (error) {
|
||||
console.error('[IAPService] 完成交易失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有未完成的交易
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async checkPendingPurchases() {
|
||||
if (Platform.OS !== 'ios') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const purchases = await getAvailablePurchases();
|
||||
const ourProductIds = getAllProductIds();
|
||||
|
||||
return purchases.filter(p => ourProductIds.includes(p.productId));
|
||||
} catch (error) {
|
||||
console.error('[IAPService] 检查待处理购买失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
async cleanup() {
|
||||
try {
|
||||
// 移除监听器
|
||||
if (this.purchaseUpdateSubscription) {
|
||||
this.purchaseUpdateSubscription.remove();
|
||||
this.purchaseUpdateSubscription = null;
|
||||
}
|
||||
if (this.purchaseErrorSubscription) {
|
||||
this.purchaseErrorSubscription.remove();
|
||||
this.purchaseErrorSubscription = null;
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
if (this.isInitialized) {
|
||||
await endConnection();
|
||||
}
|
||||
|
||||
this.isInitialized = false;
|
||||
this.products = [];
|
||||
this.subscriptions = [];
|
||||
|
||||
console.log('[IAPService] 资源已清理');
|
||||
} catch (error) {
|
||||
console.error('[IAPService] 清理资源失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const iapService = new IAPService();
|
||||
export default iapService;
|
||||
440
app.py
440
app.py
@@ -1646,6 +1646,8 @@ class PaymentOrder(db.Model):
|
||||
payment_method = db.Column(db.String(20), default='wechat') # 支付方式: wechat/alipay
|
||||
wechat_order_id = db.Column(db.String(64), nullable=True) # 微信交易号
|
||||
alipay_trade_no = db.Column(db.String(64), nullable=True) # 支付宝交易号
|
||||
transaction_id = db.Column(db.String(64), nullable=True, index=True) # 苹果交易ID (Apple IAP)
|
||||
payment_source = db.Column(db.String(20), default='web') # 支付来源: web/ios/android
|
||||
prepay_id = db.Column(db.String(64), nullable=True)
|
||||
qr_code_url = db.Column(db.String(200), nullable=True) # 微信支付二维码URL
|
||||
pay_url = db.Column(db.String(2000), nullable=True) # 支付宝支付链接(较长)
|
||||
@@ -3721,6 +3723,444 @@ def check_alipay_order_status_by_no(order_no):
|
||||
return jsonify({'success': False, 'error': '查询失败'}), 500
|
||||
|
||||
|
||||
# ===================== 苹果支付 (Apple In-App Purchase) =====================
|
||||
|
||||
# 苹果收据验证 URL
|
||||
APPLE_VERIFY_RECEIPT_URL_SANDBOX = 'https://sandbox.itunes.apple.com/verifyReceipt'
|
||||
APPLE_VERIFY_RECEIPT_URL_PRODUCTION = 'https://buy.itunes.apple.com/verifyReceipt'
|
||||
|
||||
# 苹果共享密钥 (在 App Store Connect 中获取)
|
||||
# 重要:生产环境必须通过环境变量设置,不要使用默认值
|
||||
APPLE_SHARED_SECRET = os.environ.get('APPLE_SHARED_SECRET')
|
||||
if not APPLE_SHARED_SECRET:
|
||||
print("⚠️ 警告: APPLE_SHARED_SECRET 环境变量未设置,苹果支付功能将无法正常工作")
|
||||
APPLE_SHARED_SECRET = '' # 空值,API 调用时会失败
|
||||
|
||||
# 产品 ID 到套餐的映射
|
||||
APPLE_PRODUCT_MAP = {
|
||||
'com.valuefrontier.meagent.pro_monthly': {'plan': 'pro', 'cycle': 'monthly', 'months': 1},
|
||||
'com.valuefrontier.meagent.pro_quarterly': {'plan': 'pro', 'cycle': 'quarterly', 'months': 3},
|
||||
'com.valuefrontier.meagent.pro_semiannual': {'plan': 'pro', 'cycle': 'semiannual', 'months': 6},
|
||||
'com.valuefrontier.meagent.pro_yearly': {'plan': 'pro', 'cycle': 'yearly', 'months': 12},
|
||||
'com.valuefrontier.meagent.max_monthly': {'plan': 'max', 'cycle': 'monthly', 'months': 1},
|
||||
'com.valuefrontier.meagent.max_quarterly': {'plan': 'max', 'cycle': 'quarterly', 'months': 3},
|
||||
'com.valuefrontier.meagent.max_semiannual': {'plan': 'max', 'cycle': 'semiannual', 'months': 6},
|
||||
'com.valuefrontier.meagent.max_yearly': {'plan': 'max', 'cycle': 'yearly', 'months': 12},
|
||||
}
|
||||
|
||||
|
||||
def verify_apple_receipt(receipt_data, use_sandbox=False):
|
||||
"""
|
||||
向苹果服务器验证收据
|
||||
|
||||
Args:
|
||||
receipt_data: Base64编码的收据数据
|
||||
use_sandbox: 是否使用沙盒环境
|
||||
|
||||
Returns:
|
||||
dict: 苹果返回的验证结果
|
||||
"""
|
||||
import requests
|
||||
|
||||
url = APPLE_VERIFY_RECEIPT_URL_SANDBOX if use_sandbox else APPLE_VERIFY_RECEIPT_URL_PRODUCTION
|
||||
|
||||
payload = {
|
||||
'receipt-data': receipt_data,
|
||||
'password': APPLE_SHARED_SECRET,
|
||||
'exclude-old-transactions': True
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, timeout=30)
|
||||
result = response.json()
|
||||
|
||||
# 状态码 21007 表示这是一个沙盒收据但发送到了生产环境
|
||||
# 需要重新发送到沙盒环境
|
||||
if result.get('status') == 21007 and not use_sandbox:
|
||||
return verify_apple_receipt(receipt_data, use_sandbox=True)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[Apple IAP] 验证收据失败: {e}")
|
||||
return {'status': -1, 'error': str(e)}
|
||||
|
||||
|
||||
@app.route('/api/payment/apple/verify-receipt', methods=['POST'])
|
||||
def verify_apple_receipt_api():
|
||||
"""
|
||||
验证苹果内购收据并激活订阅
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"receipt_data": "Base64编码的收据数据",
|
||||
"product_id": "com.valuefrontier.meagent.pro_monthly",
|
||||
"plan_name": "pro",
|
||||
"billing_cycle": "monthly"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
data = request.get_json()
|
||||
receipt_data = data.get('receipt_data')
|
||||
product_id = data.get('product_id')
|
||||
plan_name = data.get('plan_name')
|
||||
billing_cycle = data.get('billing_cycle')
|
||||
|
||||
if not receipt_data:
|
||||
return jsonify({'success': False, 'error': '收据数据不能为空'}), 400
|
||||
|
||||
if not product_id or product_id not in APPLE_PRODUCT_MAP:
|
||||
return jsonify({'success': False, 'error': '无效的产品 ID'}), 400
|
||||
|
||||
# 获取产品信息
|
||||
product_info = APPLE_PRODUCT_MAP[product_id]
|
||||
|
||||
# 验证 plan_name 和 billing_cycle 是否匹配
|
||||
if plan_name and plan_name != product_info['plan']:
|
||||
return jsonify({'success': False, 'error': '套餐信息不匹配'}), 400
|
||||
if billing_cycle and billing_cycle != product_info['cycle']:
|
||||
return jsonify({'success': False, 'error': '计费周期不匹配'}), 400
|
||||
|
||||
# 向苹果验证收据
|
||||
verify_result = verify_apple_receipt(receipt_data)
|
||||
|
||||
if verify_result.get('status') != 0:
|
||||
error_messages = {
|
||||
21000: '苹果服务器无法读取请求的JSON',
|
||||
21002: '收据数据格式错误',
|
||||
21003: '收据无法验证',
|
||||
21004: '共享密钥不匹配',
|
||||
21005: '收据服务器暂时不可用',
|
||||
21006: '收据有效但订阅已过期',
|
||||
21007: '收据为沙盒收据(已自动处理)',
|
||||
21008: '收据为生产收据(已自动处理)',
|
||||
21010: '此收据无法被授权',
|
||||
}
|
||||
status_code = verify_result.get('status', -1)
|
||||
error_msg = error_messages.get(status_code, f'验证失败 (status: {status_code})')
|
||||
return jsonify({'success': False, 'error': error_msg}), 400
|
||||
|
||||
# 解析收据信息
|
||||
receipt = verify_result.get('receipt', {})
|
||||
latest_receipt_info = verify_result.get('latest_receipt_info', [])
|
||||
|
||||
# 查找对应的购买记录
|
||||
purchase_info = None
|
||||
for item in latest_receipt_info:
|
||||
if item.get('product_id') == product_id:
|
||||
purchase_info = item
|
||||
break
|
||||
|
||||
if not purchase_info:
|
||||
# 尝试从 in_app 中查找
|
||||
in_app = receipt.get('in_app', [])
|
||||
for item in in_app:
|
||||
if item.get('product_id') == product_id:
|
||||
purchase_info = item
|
||||
break
|
||||
|
||||
if not purchase_info:
|
||||
return jsonify({'success': False, 'error': '未找到对应的购买记录'}), 400
|
||||
|
||||
# 获取交易信息
|
||||
transaction_id = purchase_info.get('transaction_id') or purchase_info.get('original_transaction_id')
|
||||
|
||||
# 检查订阅是否过期(对于订阅类产品)
|
||||
expires_date_ms = purchase_info.get('expires_date_ms')
|
||||
if expires_date_ms:
|
||||
try:
|
||||
from datetime import datetime
|
||||
expires_date = datetime.fromtimestamp(int(expires_date_ms) / 1000)
|
||||
if expires_date < datetime.now():
|
||||
print(f"[Apple IAP] 订阅已过期: expires_date={expires_date}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '订阅已过期,请重新购买',
|
||||
'expired_at': expires_date.isoformat()
|
||||
}), 400
|
||||
except Exception as exp_error:
|
||||
print(f"[Apple IAP] 解析过期时间失败: {exp_error}")
|
||||
|
||||
# 检查是否已经处理过这个交易
|
||||
existing_order = PaymentOrder.query.filter_by(
|
||||
transaction_id=transaction_id
|
||||
).first()
|
||||
|
||||
if existing_order:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '此交易已处理',
|
||||
'data': {
|
||||
'order_id': existing_order.id,
|
||||
'already_processed': True
|
||||
}
|
||||
})
|
||||
|
||||
# 创建订单记录
|
||||
try:
|
||||
order = PaymentOrder(
|
||||
user_id=session['user_id'],
|
||||
plan_name=product_info['plan'],
|
||||
billing_cycle=product_info['cycle'],
|
||||
amount=0, # 苹果支付的金额由苹果处理,这里记录0
|
||||
original_amount=0,
|
||||
discount_amount=0
|
||||
)
|
||||
# 设置苹果支付特有的属性
|
||||
order.status = 'paid'
|
||||
order.transaction_id = transaction_id
|
||||
order.payment_method = 'apple'
|
||||
order.payment_source = 'ios'
|
||||
order.remark = f"苹果内购 - {product_id}"
|
||||
order.paid_at = beijing_now()
|
||||
db.session.add(order)
|
||||
|
||||
# 激活用户订阅
|
||||
subscription = activate_user_subscription(
|
||||
session['user_id'],
|
||||
product_info['plan'],
|
||||
product_info['cycle']
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
print(f"[Apple IAP] 订阅激活成功: user_id={session['user_id']}, plan={product_info['plan']}, cycle={product_info['cycle']}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '订阅激活成功',
|
||||
'data': {
|
||||
'order_id': order.id,
|
||||
'plan': product_info['plan'],
|
||||
'cycle': product_info['cycle'],
|
||||
'end_date': subscription.end_date.isoformat() if subscription else None
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"[Apple IAP] 激活订阅失败: {e}")
|
||||
return jsonify({'success': False, 'error': f'激活订阅失败: {str(e)}'}), 500
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': f'处理失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
@app.route('/api/payment/apple/subscription-status', methods=['GET'])
|
||||
def get_apple_subscription_status():
|
||||
"""
|
||||
获取用户的苹果订阅状态
|
||||
用于检查订阅是否有效、是否需要续费等
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
# 获取用户订阅信息
|
||||
subscription = get_user_subscription_safe(session['user_id'])
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'is_active': subscription.is_active if subscription else False,
|
||||
'plan': subscription.subscription_type if subscription else None,
|
||||
'cycle': subscription.billing_cycle if subscription else None,
|
||||
'end_date': subscription.end_date.isoformat() if subscription and subscription.end_date else None,
|
||||
'days_left': subscription.days_left if subscription else 0
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'获取订阅状态失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
@app.route('/api/payment/apple/webhook', methods=['POST'])
|
||||
def apple_webhook():
|
||||
"""
|
||||
处理苹果服务器推送的订阅状态通知 (App Store Server Notifications V2)
|
||||
|
||||
苹果会在以下事件发生时推送通知:
|
||||
- SUBSCRIBED: 用户订阅
|
||||
- DID_RENEW: 自动续费成功
|
||||
- DID_FAIL_TO_RENEW: 续费失败
|
||||
- DID_CHANGE_RENEWAL_STATUS: 用户更改续订状态(取消/恢复自动续订)
|
||||
- EXPIRED: 订阅过期
|
||||
- GRACE_PERIOD_EXPIRED: 宽限期过期
|
||||
- REFUND: 退款
|
||||
- REVOKE: 撤销(家庭共享移除等)
|
||||
|
||||
文档: https://developer.apple.com/documentation/appstoreservernotifications
|
||||
"""
|
||||
try:
|
||||
# 获取请求数据
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
print("[Apple Webhook] 收到空请求")
|
||||
return jsonify({'success': False}), 400
|
||||
|
||||
# V2 通知格式使用 signedPayload
|
||||
signed_payload = data.get('signedPayload')
|
||||
|
||||
if signed_payload:
|
||||
# V2 格式: 需要解码 JWT
|
||||
# TODO: 实现 JWT 签名验证(使用苹果的公钥)
|
||||
# 目前先解码 payload 部分(不验证签名,生产环境应验证)
|
||||
try:
|
||||
import base64
|
||||
import json as json_lib
|
||||
|
||||
# JWT 格式: header.payload.signature
|
||||
parts = signed_payload.split('.')
|
||||
if len(parts) >= 2:
|
||||
# 解码 payload(第二部分)
|
||||
payload_b64 = parts[1]
|
||||
# 添加 padding
|
||||
padding = 4 - len(payload_b64) % 4
|
||||
if padding != 4:
|
||||
payload_b64 += '=' * padding
|
||||
|
||||
payload_json = base64.urlsafe_b64decode(payload_b64)
|
||||
notification_data = json_lib.loads(payload_json)
|
||||
else:
|
||||
print("[Apple Webhook] 无效的 JWT 格式")
|
||||
return jsonify({'success': False}), 400
|
||||
except Exception as decode_error:
|
||||
print(f"[Apple Webhook] 解码 JWT 失败: {decode_error}")
|
||||
return jsonify({'success': False}), 400
|
||||
else:
|
||||
# V1 格式(旧版,直接使用 data)
|
||||
notification_data = data
|
||||
|
||||
# 提取通知类型
|
||||
notification_type = notification_data.get('notificationType') or notification_data.get('notification_type')
|
||||
subtype = notification_data.get('subtype', '')
|
||||
|
||||
print(f"[Apple Webhook] 收到通知: type={notification_type}, subtype={subtype}")
|
||||
|
||||
# 提取交易信息
|
||||
transaction_info = None
|
||||
if 'data' in notification_data:
|
||||
# V2 格式
|
||||
signed_transaction = notification_data['data'].get('signedTransactionInfo')
|
||||
if signed_transaction:
|
||||
# 解码交易信息
|
||||
try:
|
||||
parts = signed_transaction.split('.')
|
||||
if len(parts) >= 2:
|
||||
payload_b64 = parts[1]
|
||||
padding = 4 - len(payload_b64) % 4
|
||||
if padding != 4:
|
||||
payload_b64 += '=' * padding
|
||||
transaction_info = json_lib.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 获取原始交易 ID(用于查找用户)
|
||||
original_transaction_id = None
|
||||
if transaction_info:
|
||||
original_transaction_id = transaction_info.get('originalTransactionId')
|
||||
elif 'unified_receipt' in notification_data:
|
||||
# V1 格式
|
||||
latest_receipt_info = notification_data.get('unified_receipt', {}).get('latest_receipt_info', [])
|
||||
if latest_receipt_info:
|
||||
original_transaction_id = latest_receipt_info[0].get('original_transaction_id')
|
||||
|
||||
if not original_transaction_id:
|
||||
print("[Apple Webhook] 未找到 original_transaction_id")
|
||||
# 仍然返回成功,避免苹果重试
|
||||
return jsonify({'success': True})
|
||||
|
||||
# 查找对应的订单
|
||||
order = PaymentOrder.query.filter_by(transaction_id=original_transaction_id).first()
|
||||
|
||||
if not order:
|
||||
print(f"[Apple Webhook] 未找到订单: transaction_id={original_transaction_id}")
|
||||
return jsonify({'success': True})
|
||||
|
||||
user_id = order.user_id
|
||||
|
||||
# 根据通知类型处理
|
||||
if notification_type in ['DID_RENEW', 'SUBSCRIBED']:
|
||||
# 续费成功或新订阅
|
||||
print(f"[Apple Webhook] 续费/订阅成功: user_id={user_id}")
|
||||
|
||||
# 延长订阅时间
|
||||
subscription = get_user_subscription_safe(user_id)
|
||||
if subscription:
|
||||
# 获取订阅周期
|
||||
product_info = APPLE_PRODUCT_MAP.get(order.remark.replace('苹果内购 - ', ''))
|
||||
if product_info:
|
||||
months = product_info.get('months', 1)
|
||||
if subscription.end_date and subscription.end_date > beijing_now():
|
||||
# 在现有到期时间基础上延长
|
||||
subscription.end_date = subscription.end_date + timedelta(days=30 * months)
|
||||
else:
|
||||
# 从现在开始计算
|
||||
subscription.end_date = beijing_now() + timedelta(days=30 * months)
|
||||
subscription.is_active = True
|
||||
db.session.commit()
|
||||
print(f"[Apple Webhook] 订阅已延长至: {subscription.end_date}")
|
||||
|
||||
elif notification_type == 'EXPIRED':
|
||||
# 订阅过期
|
||||
print(f"[Apple Webhook] 订阅过期: user_id={user_id}")
|
||||
subscription = get_user_subscription_safe(user_id)
|
||||
if subscription:
|
||||
subscription.is_active = False
|
||||
subscription.auto_renewal = False
|
||||
db.session.commit()
|
||||
|
||||
elif notification_type == 'DID_CHANGE_RENEWAL_STATUS':
|
||||
# 用户更改自动续订状态
|
||||
auto_renew = subtype != 'AUTO_RENEW_DISABLED'
|
||||
print(f"[Apple Webhook] 自动续订状态变更: user_id={user_id}, auto_renew={auto_renew}")
|
||||
subscription = get_user_subscription_safe(user_id)
|
||||
if subscription:
|
||||
subscription.auto_renewal = auto_renew
|
||||
db.session.commit()
|
||||
|
||||
elif notification_type in ['REFUND', 'REVOKE']:
|
||||
# 退款或撤销
|
||||
print(f"[Apple Webhook] 退款/撤销: user_id={user_id}")
|
||||
subscription = get_user_subscription_safe(user_id)
|
||||
if subscription:
|
||||
subscription.is_active = False
|
||||
subscription.auto_renewal = False
|
||||
db.session.commit()
|
||||
|
||||
# 更新订单状态
|
||||
order.status = 'refunded'
|
||||
order.remark = f"{order.remark} - {notification_type}"
|
||||
db.session.commit()
|
||||
|
||||
elif notification_type == 'DID_FAIL_TO_RENEW':
|
||||
# 续费失败(进入宽限期或计费重试期)
|
||||
print(f"[Apple Webhook] 续费失败: user_id={user_id}, subtype={subtype}")
|
||||
# 可以在这里发送提醒通知给用户
|
||||
|
||||
elif notification_type == 'GRACE_PERIOD_EXPIRED':
|
||||
# 宽限期结束
|
||||
print(f"[Apple Webhook] 宽限期结束: user_id={user_id}")
|
||||
subscription = get_user_subscription_safe(user_id)
|
||||
if subscription:
|
||||
subscription.is_active = False
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"[Apple Webhook] 处理失败: {e}")
|
||||
# 返回成功以避免苹果重试(错误已记录)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/auth/session', methods=['GET'])
|
||||
def get_session_info():
|
||||
"""获取当前登录用户信息"""
|
||||
|
||||
59
migrations/add_apple_payment_fields.sql
Normal file
59
migrations/add_apple_payment_fields.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- ============================================================
|
||||
-- 数据库迁移: 添加苹果支付相关字段
|
||||
-- 表: payment_orders
|
||||
-- 日期: 2025-01-21
|
||||
-- 描述: 为支持 Apple IAP (In-App Purchase) 添加必要的字段
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 添加新字段
|
||||
-- ============================================================
|
||||
|
||||
-- 添加苹果交易ID字段 (用于存储 Apple 的 transaction_id 或 original_transaction_id)
|
||||
ALTER TABLE payment_orders
|
||||
ADD COLUMN IF NOT EXISTS transaction_id VARCHAR(64) NULL
|
||||
COMMENT '苹果交易ID (Apple IAP transaction_id)';
|
||||
|
||||
-- 添加支付来源字段 (区分 web/ios/android 等来源)
|
||||
ALTER TABLE payment_orders
|
||||
ADD COLUMN IF NOT EXISTS payment_source VARCHAR(20) DEFAULT 'web'
|
||||
COMMENT '支付来源: web/ios/android';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 创建索引 (提升查询性能)
|
||||
-- ============================================================
|
||||
|
||||
-- 为 transaction_id 创建索引 (用于防重复和 Webhook 查找)
|
||||
-- 使用 IF NOT EXISTS 语法(MySQL 8.0+)或先检查再创建
|
||||
CREATE INDEX idx_payment_orders_transaction_id
|
||||
ON payment_orders(transaction_id);
|
||||
|
||||
-- 为 payment_source 创建索引 (用于统计分析)
|
||||
CREATE INDEX idx_payment_orders_payment_source
|
||||
ON payment_orders(payment_source);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 验证迁移结果
|
||||
-- ============================================================
|
||||
|
||||
-- 查看表结构确认字段已添加
|
||||
-- DESCRIBE payment_orders;
|
||||
|
||||
-- 查看索引确认已创建
|
||||
-- SHOW INDEX FROM payment_orders WHERE Key_name LIKE '%transaction%' OR Key_name LIKE '%payment_source%';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 回滚脚本 (如需回滚,执行以下 SQL)
|
||||
-- ============================================================
|
||||
/*
|
||||
-- 删除索引
|
||||
DROP INDEX idx_payment_orders_transaction_id ON payment_orders;
|
||||
DROP INDEX idx_payment_orders_payment_source ON payment_orders;
|
||||
|
||||
-- 删除字段
|
||||
ALTER TABLE payment_orders DROP COLUMN transaction_id;
|
||||
ALTER TABLE payment_orders DROP COLUMN payment_source;
|
||||
*/
|
||||
76
migrations/add_apple_payment_fields_mysql57.sql
Normal file
76
migrations/add_apple_payment_fields_mysql57.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
-- ============================================================
|
||||
-- 数据库迁移: 添加苹果支付相关字段 (MySQL 5.7 兼容版)
|
||||
-- 表: payment_orders
|
||||
-- 日期: 2025-01-21
|
||||
-- 描述: 为支持 Apple IAP (In-App Purchase) 添加必要的字段
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 执行前检查 (可选)
|
||||
-- ============================================================
|
||||
-- 查看当前表结构
|
||||
-- DESCRIBE payment_orders;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 添加新字段 (MySQL 5.7 兼容语法)
|
||||
-- ============================================================
|
||||
|
||||
-- 先检查字段是否存在,如果不存在则添加
|
||||
-- 注意:MySQL 5.7 不支持 IF NOT EXISTS,需要手动检查或使用存储过程
|
||||
|
||||
-- 方式一:直接执行(如果字段已存在会报错,可忽略)
|
||||
ALTER TABLE payment_orders
|
||||
ADD COLUMN transaction_id VARCHAR(64) NULL
|
||||
COMMENT '苹果交易ID (Apple IAP transaction_id)';
|
||||
|
||||
ALTER TABLE payment_orders
|
||||
ADD COLUMN payment_source VARCHAR(20) DEFAULT 'web'
|
||||
COMMENT '支付来源: web/ios/android';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 创建索引
|
||||
-- ============================================================
|
||||
|
||||
-- 为 transaction_id 创建索引
|
||||
ALTER TABLE payment_orders
|
||||
ADD INDEX idx_payment_orders_transaction_id (transaction_id);
|
||||
|
||||
-- 为 payment_source 创建索引
|
||||
ALTER TABLE payment_orders
|
||||
ADD INDEX idx_payment_orders_payment_source (payment_source);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 更新现有数据的 payment_source (可选)
|
||||
-- ============================================================
|
||||
|
||||
-- 将所有现有订单的 payment_source 设置为 'web'(如果为空)
|
||||
UPDATE payment_orders
|
||||
SET payment_source = 'web'
|
||||
WHERE payment_source IS NULL OR payment_source = '';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 验证迁移结果
|
||||
-- ============================================================
|
||||
|
||||
-- 查看表结构确认字段已添加
|
||||
DESCRIBE payment_orders;
|
||||
|
||||
-- 查看索引确认已创建
|
||||
SHOW INDEX FROM payment_orders WHERE Key_name LIKE 'idx_payment_orders%';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 回滚脚本 (如需回滚,执行以下 SQL)
|
||||
-- ============================================================
|
||||
/*
|
||||
-- 删除索引
|
||||
ALTER TABLE payment_orders DROP INDEX idx_payment_orders_transaction_id;
|
||||
ALTER TABLE payment_orders DROP INDEX idx_payment_orders_payment_source;
|
||||
|
||||
-- 删除字段
|
||||
ALTER TABLE payment_orders DROP COLUMN transaction_id;
|
||||
ALTER TABLE payment_orders DROP COLUMN payment_source;
|
||||
*/
|
||||
@@ -56,6 +56,19 @@ const AgentChat = () => {
|
||||
// ==================== MAX 权限检查 ====================
|
||||
const isMaxUser = user?.subscription_type === 'max' || hasSubscriptionLevel('max');
|
||||
|
||||
// 🔍 调试日志:排查会员权限问题
|
||||
React.useEffect(() => {
|
||||
console.log('🔍 [AgentChat] 会员权限调试信息:', {
|
||||
isAuthenticated,
|
||||
userId: user?.id,
|
||||
userSubscriptionType: user?.subscription_type,
|
||||
userSubscriptionStatus: user?.subscription_status,
|
||||
hasMaxLevel: hasSubscriptionLevel('max'),
|
||||
isMaxUser,
|
||||
fullUser: user
|
||||
});
|
||||
}, [user, isAuthenticated, isMaxUser]);
|
||||
|
||||
// ==================== 聊天模式状态 ====================
|
||||
const [chatMode, setChatMode] = useState(ChatMode.SINGLE);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user