更新ios

This commit is contained in:
2026-01-21 15:54:04 +08:00
parent 0b2185777e
commit 907bca509a
12 changed files with 1745 additions and 6 deletions

View File

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

View File

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

View File

@@ -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') {

View 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;

View File

@@ -0,0 +1,2 @@
export { default as SubscriptionScreen } from './SubscriptionScreen';
export { default } from './SubscriptionScreen';

View File

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

View File

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

View 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
View File

@@ -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():
"""获取当前登录用户信息"""

View 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;
*/

View 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;
*/

View File

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