fix:小组件- PUSH

This commit is contained in:
吕新雨
2026-02-03 17:43:58 +08:00
parent d742b398ef
commit c1c2c6197d
66 changed files with 4888 additions and 479 deletions

View File

@@ -1,5 +1,69 @@
请开始完成编码
客户端请按照标准的RN架构目录写代码
客户端API请求 统一使用utlis中封装的请求
客户端的架构目录参考
project-root
├── android/ # Android 原生工程
├── ios/ # iOS 原生工程
├── src/ # 业务代码主目录 ⭐⭐⭐
│ ├── app.tsx # App 入口(注册 Provider / Navigation
│ ├── navigation/ # 路由导航
│ │ ├── index.tsx
│ │ ├── RootNavigator.tsx
│ │ └── types.ts
│ ├── screens/ # 页面Screen 级别)
│ │ ├── Home/
│ │ │ ├── index.tsx
│ │ │ ├── styles.ts
│ │ │ └── hooks.ts
│ │ └── Profile/
│ ├── components/ # 通用 UI 组件(无业务)
│ │ ├── Button/
│ │ │ ├── index.tsx
│ │ │ └── styles.ts
│ │ └── Empty/
│ ├── modules/ # 业务模块(强烈推荐)
│ │ ├── user/
│ │ │ ├── api.ts
│ │ │ ├── model.ts
│ │ │ ├── store.ts
│ │ │ └── index.ts
│ │ └── emotion/
│ ├── services/ # 跨模块服务(网络、存储等)
│ │ ├── http.ts # axios/fetch 封装
│ │ ├── storage.ts # AsyncStorage 封装
│ │ └── logger.ts
│ ├── store/ # 全局状态Redux / Zustand / Jotai
│ │ ├── index.ts
│ │ └── middleware.ts
│ ├── hooks/ # 全局通用 hooks
│ │ ├── useTheme.ts
│ │ └── useDebounce.ts
│ ├── utils/ # 工具函数
│ │ ├── date.ts
│ │ └── uuid.ts
│ ├── constants/ # 常量
│ │ ├── colors.ts
│ │ ├── env.ts
│ │ └── storageKeys.ts
│ ├── assets/ # 静态资源
│ │ ├── images/
│ │ ├── icons/
│ │ └── fonts/
│ ├── theme/ # 主题系统
│ │ ├── index.ts
│ │ └── dark.ts
│ └── types/ # 全局 TS 类型
│ └── index.d.ts
├── __tests__/ # 测试
├── .env # 环境变量
├── babel.config.js
├── metro.config.js
├── tsconfig.json
├── package.json
└── index.js # RN 启动入口
后端请按照标准的python FastAPI 架构目录写代码
现在多语言仅支持 EN / TC
整个task.md执行完毕后需要在对应的overview.md标记并且说明变更的文件名

View File

@@ -1,9 +1,22 @@
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
export default function AppLayout() {
const { t } = useTranslation();
useEffect(() => {
// 小组件数据同步(仅 iOS 生效;内部会判断原生模块是否可用)
// 目的:避免“仅 Onboarding 写入一次”导致老用户小组件一直显示兜底文案
void (async () => {
await syncWidgetConfig();
await syncWidgetUserProfileFromStorage();
await ensureDailyWidgetRecoUpToDate({ reason: 'app_start' });
})();
}, []);
return (
<Stack
screenOptions={{
@@ -13,6 +26,8 @@ export default function AppLayout() {
<Stack.Screen
name="home"
options={{
// Home 页不使用系统 Header避免 iOS 原生导航栏自带的“毛玻璃/液玻璃”材质
headerShown: false,
// 卡片页标题按设计留空(右上角为 icon 按钮)
title: '',
headerShadowVisible: false,

View File

@@ -1,7 +1,8 @@
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useNavigation, useFocusEffect } from 'expo-router';
import { useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
Easing,
runOnJS,
@@ -78,7 +79,7 @@ export default function HomeScreen() {
const { t, i18n } = useTranslation();
const isEnglish = i18n.language?.startsWith('en');
const recoLang: 'en' | 'tc' = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [index, setIndex] = useState(0);
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
const [themeOpen, setThemeOpen] = useState(false);
@@ -210,32 +211,6 @@ export default function HomeScreen() {
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
useLayoutEffect(() => {
navigation.setOptions({
headerShadowVisible: false,
// 为了让风景/颜色两种主题下“文案的视觉居中位置”一致,统一使用透明 Header
// 颜色主题下 Header 透明也不会影响观感(背景就是纯色)
headerStyle: { backgroundColor: 'transparent' },
headerTransparent: true,
headerRight: () => (
<View style={styles.headerRight}>
<CircleIconButton
onPress={() => setThemeOpen(true)}
accessibilityLabel={t('home.theme')}
>
<ThemeIcon width={18} height={18} />
</CircleIconButton>
<CircleIconButton
onPress={() => setProfileOpen(true)}
accessibilityLabel={t('home.profile')}
>
<MyIcon width={18} height={18} />
</CircleIconButton>
</View>
),
});
}, [backgroundColor, themeMode, navigation, t]);
const textAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
opacity: opacity.value,
@@ -368,6 +343,23 @@ export default function HomeScreen() {
resizeMode="cover"
/>
)}
{/* 自绘顶部按钮:不使用系统 Header彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */}
<View style={[styles.topRight, { top: insets.top + 8 }]}>
<CircleIconButton
onPress={() => setThemeOpen(true)}
accessibilityLabel={t('home.theme')}
>
<ThemeIcon width={18} height={18} />
</CircleIconButton>
<CircleIconButton
onPress={() => setProfileOpen(true)}
accessibilityLabel={t('home.profile')}
>
<MyIcon width={18} height={18} />
</CircleIconButton>
</View>
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
{item.text}
@@ -384,12 +376,12 @@ export default function HomeScreen() {
style={styles.reactionInner}
>
{likeFilled ? (
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
<LikeFilledIcon width={35} height={36} color="#EA6969" />
) : (
<LikeIcon
width={35}
height={36}
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
/>
)}
</Pressable>
@@ -435,10 +427,12 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
headerRight: {
topRight: {
position: 'absolute',
right: 20,
flexDirection: 'row',
gap: 10,
paddingRight: 10,
zIndex: 30,
},
circleBtn: {
width: 34,

View File

@@ -12,7 +12,6 @@ export default function OnboardingLayout() {
}}
>
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
<Stack.Screen name="push-prompt" options={{ title: t('push.title') }} />
</Stack>
);
}

View File

@@ -1,13 +1,17 @@
import { useMemo, useState } from 'react';
import { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';
import { useTranslation } from 'react-i18next';
import { Alert } from 'react-native';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
import { NameInputStep } from '@/components/onboarding/NameInputStep';
import { SelectionStep } from '@/components/onboarding/SelectionStep';
import { ReminderStep } from '@/components/onboarding/ReminderStep';
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
import { fetchRecoFeed } from '@/src/services/recoApi';
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
import {
recordRecoFeedServed,
setOnboardingCompleted,
@@ -15,6 +19,7 @@ import {
setDailyReminderSettings,
setUserProfileScoring,
setRecoFeedCache,
setPushPromptState,
} from '@/src/storage/appStorage';
type Step =
@@ -50,9 +55,8 @@ export default function OnboardingScreen() {
}, [t, currentStep]);
async function onFinish() {
// 请求推送权限
const { status } = await Notifications.requestPermissionsAsync();
const pushEnabled = status === 'granted';
// 用户选择每日次数 > 0在此页直接触发系统通知权限已移除单独的 push 引导页)。
const wantsPush = reminderTimes > 0;
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
@@ -61,6 +65,13 @@ export default function OnboardingScreen() {
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
await setUserProfileScoring(scoringProfile);
// 同步到 App Group供 iOS Widget 拉取与展示
await syncWidgetConfig();
await syncWidgetUserProfileFromScoring(scoringProfile);
// 可选:前台辅助拉取一次“每日推荐”,提升小组件首次展示的成功率与一致性(失败不阻塞)
await ensureDailyWidgetRecoUpToDate({ reason: 'onboarding_finish', scoringProfile });
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
try {
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
@@ -96,10 +107,49 @@ export default function OnboardingScreen() {
});
await setDailyReminderSettings({
timesPerDay: reminderTimes,
pushEnabled: pushEnabled
// 这里表示“用户意愿”,不代表系统权限一定已 granted
pushEnabled: wantsPush,
});
await setOnboardingCompleted(true);
// 用户选择 0 次(关闭)或跳过:直接进入首页
if (!wantsPush) {
await setPushPromptState('skipped');
router.replace('/(app)/home');
return;
}
// 用户想要 Push请求系统权限并尽量完成 token/偏好上报(失败不阻塞进入首页)
await setPushPromptState('unknown');
try {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
await setPushPromptState('skipped');
return;
}
// iOS 模拟器通常无法获取 Expo Push Token系统限制此时不要提示“失败”而是明确告知需要真机测试。
if (Device.osName === 'iOS' && !Device.isDevice) {
Alert.alert('提示', '当前为 iOS 模拟器,无法获取推送 Token。请使用真机测试推送功能。');
await setPushPromptState('unknown');
return;
}
// 1) 获取 Expo Push Token
const expoPushToken = await getExpoPushTokenOrThrow();
// 2) 上报 token 到后端(幂等)
await registerPushToken({ pushToken: expoPushToken });
// 3) 上报推送偏好(幂等)
await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
await setPushPromptState('enabled');
} catch (e) {
// 失败不阻塞进入首页;但这里给出更明确的文案(常见原因:模拟器/网络/后端异常)
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
await setPushPromptState('unknown');
} finally {
router.replace('/(app)/home');
}
}
const onNext = () => {
@@ -121,6 +171,10 @@ export default function OnboardingScreen() {
const scoringProfile = buildUserProfileFromQuestionnaire({});
await setUserProfileScoring(scoringProfile);
// 同步到 App Group供 iOS Widget 使用(失败不阻塞)
await syncWidgetConfig();
await syncWidgetUserProfileFromScoring(scoringProfile);
// 标记已完成,避免下次启动再次进入 Onboarding
await setOnboardingCompleted(true);
router.replace('/(app)/home');
@@ -173,6 +227,11 @@ export default function OnboardingScreen() {
value={reminderTimes}
onChange={setReminderTimes}
onFinish={onFinish}
onSkip={() => {
// 跳过每日提醒:视为 0 次(关闭)
setReminderTimes(0);
onFinish();
}}
/>
)}
</OnboardingLayout>

View File

@@ -1,74 +0,0 @@
import { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import * as Notifications from 'expo-notifications';
import { setPushPromptState } from '@/src/storage/appStorage';
export default function PushPromptScreen() {
const router = useRouter();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
async function goHome() {
router.replace('/(app)/home');
}
async function onLater() {
await setPushPromptState('skipped');
await goHome();
}
async function onEnableNow() {
// 触发系统权限申请(可失败,但不阻塞进入主功能)
setLoading(true);
try {
await Notifications.requestPermissionsAsync();
await setPushPromptState('enabled');
await goHome();
} catch (e) {
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
await goHome();
} finally {
setLoading(false);
}
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>{t('push.cardTitle')}</Text>
<Text style={styles.desc}>{t('push.cardDesc')}</Text>
</View>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.secondary]} onPress={onLater} disabled={loading}>
<Text style={[styles.btnText, styles.secondaryText]}>{t('push.later')}</Text>
</Pressable>
<Pressable style={[styles.btn, styles.primary]} onPress={onEnableNow} disabled={loading}>
<Text style={styles.btnText}>{loading ? t('push.loading') : t('push.enable')}</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 },
card: {
borderRadius: 18,
padding: 20,
backgroundColor: '#111827',
gap: 10
},
title: { color: 'white', fontSize: 20, fontWeight: '700' },
desc: { color: '#E5E7EB', fontSize: 15, lineHeight: 21 },
actions: { flexDirection: 'row', gap: 12 },
btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' },
primary: { backgroundColor: '#16A34A' },
secondary: { backgroundColor: '#F3F4F6' },
btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' },
secondaryText: { color: '#111827' }
});

View File

@@ -5,6 +5,7 @@ import * as WebBrowser from 'expo-web-browser';
import { useTranslation } from 'react-i18next';
import { SafeAreaView } from 'react-native-safe-area-context';
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
import { fetchLegalLinks } from '@/src/services/legalApi';
// 导入 SVG 组件
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
@@ -16,6 +17,7 @@ export default function SplashScreen() {
const router = useRouter();
const { t } = useTranslation();
const [showConsent, setShowConsent] = useState(false);
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
useEffect(() => {
checkConsent();
@@ -44,6 +46,25 @@ export default function SplashScreen() {
}
};
// 拉取协议链接(由后端按语言下发;默认 EN
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetchLegalLinks();
if (cancelled) return;
setLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
} catch (e) {
// 不阻塞主流程:失败时不崩溃,链接入口可不展示
if (__DEV__) console.log('[LegalLinks] 拉取失败splash:', e);
if (!cancelled) setLinks({});
}
})();
return () => {
cancelled = true;
};
}, []);
const bgDecorationTop = 363;
const bgDecorationHeight = height * 0.6;
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
@@ -81,11 +102,17 @@ export default function SplashScreen() {
</TouchableOpacity>
<View style={styles.linksContainer}>
<TouchableOpacity onPress={() => openLink('https://example.com/privacy')}>
<TouchableOpacity
disabled={!links.privacy}
onPress={() => (links.privacy ? openLink(links.privacy) : undefined)}
>
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity onPress={() => openLink('https://example.com/terms')}>
<TouchableOpacity
disabled={!links.terms}
onPress={() => (links.terms ? openLink(links.terms) : undefined)}
>
<Text style={styles.linkText}>{t('consent.terms')}</Text>
</TouchableOpacity>
</View>

View File

@@ -6,9 +6,12 @@ import * as SplashScreen from 'expo-splash-screen';
import * as Notifications from 'expo-notifications';
import { useEffect, useState } from 'react';
import 'react-native-reanimated';
import { AppState } from 'react-native';
import { useColorScheme } from '@/components/useColorScheme';
import { initI18n } from '@/src/i18n';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
Notifications.setNotificationHandler({
@@ -53,6 +56,17 @@ export default function RootLayout() {
.finally(() => setI18nReady(true));
}, []);
useEffect(() => {
// 尽早生成 client_user_id便于后续任意时刻与后端建立关联Push Token/偏好等)
getOrCreateClientUserId()
.then((id) => {
if (__DEV__) console.log('[client_user_id]', id);
})
.catch((e) => {
console.warn('[client_user_id] 生成失败(不阻塞启动)', e);
});
}, []);
useEffect(() => {
// 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁
if (loaded && i18nReady) {
@@ -70,6 +84,21 @@ export default function RootLayout() {
function RootLayoutNav() {
const colorScheme = useColorScheme();
useEffect(() => {
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
syncWidgetConfig().catch(() => {});
syncWidgetUserProfileFromStorage().catch(() => {});
ensureDailyWidgetRecoUpToDate({ reason: 'app_start' }).catch(() => {});
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') {
// App 回到前台时尝试刷新(失败不阻塞)
ensureDailyWidgetRecoUpToDate({ reason: 'app_active' }).catch(() => {});
}
});
return () => sub.remove();
}, []);
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>

View File

@@ -39,13 +39,17 @@ export default function DailyReminderModal({ visible, onClose }: Props) {
}, [visible]);
function clamp(next: number) {
return Math.min(10, Math.max(1, next));
// 需求050 表示关闭)
return Math.min(5, Math.max(0, next));
}
async function onOk() {
if (loading) return;
setLoading(true);
const next: DailyReminderSettings = { timesPerDay, pushEnabled };
const next: DailyReminderSettings = {
timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))),
pushEnabled: Boolean(pushEnabled) && timesPerDay > 0,
};
await setDailyReminderSettings(next);
setLoading(false);
onClose();

View File

@@ -3,6 +3,7 @@ import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Di
import { useTranslation } from 'react-i18next';
import { LinearGradient } from 'expo-linear-gradient';
import { Switch } from 'react-native';
import * as WebBrowser from 'expo-web-browser';
import Animated, {
Easing,
FadeIn,
@@ -39,6 +40,8 @@ import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg';
import * as Notifications from 'expo-notifications';
import { changeLanguage } from '@/src/i18n';
import { fetchLegalLinks } from '@/src/services/legalApi';
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
const { width } = Dimensions.get('window');
@@ -80,6 +83,7 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
const [page, setPage] = useState<Page>('root');
const [navDirection, setNavDirection] = useState<NavDirection>('forward');
const [currentName, setCurrentName] = useState(propName);
const [legalLinks, setLegalLinks] = useState<{ privacy?: string; terms?: string }>({});
const isRoot = page === 'root';
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
@@ -90,6 +94,16 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
setCurrentName(profile.name);
}
});
// 打开弹窗时拉取协议链接(由后端按语言下发;默认 EN
fetchLegalLinks()
.then((res) => {
setLegalLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
})
.catch((e) => {
if (__DEV__) console.log('[LegalLinks] 拉取失败ProfileModal:', e);
setLegalLinks({});
});
} else {
setNavDirection('back');
setPage('root');
@@ -123,6 +137,18 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
handleClose();
}
const openLink = useCallback(
async (url?: string) => {
if (!url) return;
try {
await WebBrowser.openBrowserAsync(url);
} catch (error) {
Alert.alert(t('common.error'), t('common.openLinkError'));
}
},
[t],
);
const title = useMemo(() => {
if (page === 'favorites') return t('profile.favorites');
if (page === 'dailyReminder') return t('dailyReminder.title');
@@ -182,6 +208,8 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
onOpenWidget={() => go('widget', 'forward')}
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
onOpenLanguage={() => go('language', 'forward')}
onOpenPrivacy={() => openLink(legalLinks.privacy)}
onOpenTerms={() => openLink(legalLinks.terms)}
/>
) : page === 'favorites' ? (
<FavoritesPage visible={visible} page={page} />
@@ -211,12 +239,16 @@ function RootPage({
onOpenWidget,
onOpenDailyReminder,
onOpenLanguage,
onOpenPrivacy,
onOpenTerms,
}: {
name?: string;
onOpenFavorites: () => void;
onOpenWidget: () => void;
onOpenDailyReminder: () => void;
onOpenLanguage: () => void;
onOpenPrivacy: () => void;
onOpenTerms: () => void;
}) {
const { t } = useTranslation();
return (
@@ -244,12 +276,12 @@ function RootPage({
<ListItem
icon={<PrivacyIcon width={22} height={22} />}
title={t('profile.privacy')}
onPress={() => toastTodo(t)}
onPress={onOpenPrivacy}
/>
<ListItem
icon={<TermsIcon width={22} height={22} />}
title={t('profile.terms')}
onPress={() => toastTodo(t)}
onPress={onOpenTerms}
/>
<ListItem
icon={<LanguageIcon width={22} height={22} />}
@@ -374,12 +406,14 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
// 1. 获取本地存储设置
const s = await getDailyReminderSettings();
// 【测试模式】:强制模拟无权限状态
const granted = false;
// 2. 获取系统通知权限(用于 UI 展示/引导)
const settings = await Notifications.getPermissionsAsync();
const granted = settings.status === 'granted';
if (cancelled) return;
setTimesPerDay(s.timesPerDay);
setPushEnabled(granted);
// pushEnabled 表示用户意愿;若系统未授权则强制展示为关闭
setPushEnabled(Boolean(s.pushEnabled) && granted);
setHasSystemPermission(granted);
setLoading(false);
})();
@@ -412,6 +446,16 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
if (status === 'granted') {
setPushEnabled(true);
setHasSystemPermission(true);
// 获取 token 并上报后端(幂等)
try {
const expoPushToken = await getExpoPushTokenOrThrow();
await registerPushToken({ pushToken: expoPushToken });
await setPushPreferences({ enabled: true, timesPerDay });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
Alert.alert(t('common.notice'), msg);
}
} else {
setPushEnabled(false);
setHasSystemPermission(false);
@@ -419,18 +463,35 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
}
} else {
setPushEnabled(false);
// 关闭时尝试同步到后端(不阻塞)
try {
await setPushPreferences({ enabled: false, timesPerDay: 0 });
} catch {
// ignore
}
}
};
function clamp(next: number) {
return Math.min(10, Math.max(1, next));
// 需求050 表示关闭)
return Math.min(5, Math.max(0, next));
}
async function onOk() {
if (loading) return;
setLoading(true);
const next: DailyReminderSettings = { timesPerDay, pushEnabled };
const nextTimes = Math.min(5, Math.max(0, Math.round(timesPerDay)));
const nextEnabled = Boolean(pushEnabled) && nextTimes > 0;
const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled };
await setDailyReminderSettings(next);
// 同步后端偏好(幂等;失败不阻塞)
try {
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('[PushPreferences] 同步失败', msg);
}
setLoading(false);
onDone();
}
@@ -464,7 +525,6 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
</Pressable>
</View>
{!hasSystemPermission && (
<View style={styles.remindRow}>
<View style={styles.rowLeft}>
<View style={styles.rowIcon}>
@@ -481,7 +541,6 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
/>
</View>
</View>
)}
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
<LinearGradient
@@ -537,7 +596,8 @@ function WidgetHowToPage() {
const flatListRef = useRef<FlatList>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [isManual, setIsManual] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// React Native 环境下 setInterval 返回值类型与 Node 不同,这里用 ReturnType 兼容
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const images = currentLang === 'en' ? [
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') },

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native';
import { useTranslation } from 'react-i18next';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import AddIcon from '@/assets/images/icon/add_icon.svg';
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
@@ -11,11 +12,15 @@ interface ReminderStepProps {
value: number;
onChange: (value: number) => void;
onFinish: () => void;
onSkip: () => void;
}
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) {
const { t } = useTranslation();
const handleReduce = () => {
if (value > 1) onChange(value - 1);
// 允许 050 表示关闭每日提醒
if (value > 0) onChange(value - 1);
};
const handleAdd = () => {
@@ -31,7 +36,7 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
<View style={styles.numberWrapper}>
<Text style={styles.numberText}>{value}</Text>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('dailyReminder.timesUnit')}</Text>
</View>
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
@@ -43,6 +48,10 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
<BtnClicked width={87} height={57} />
</TouchableOpacity>
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
<Text style={styles.skipText}>{t('onboarding.skip')}</Text>
</TouchableOpacity>
</View>
</View>
);
@@ -86,4 +95,16 @@ const styles = StyleSheet.create({
bottom: height * 0.12,
alignItems: 'center',
}
,
skipBtn: {
marginTop: 14,
paddingVertical: 10,
paddingHorizontal: 18,
},
skipText: {
color: OnboardingColors.textPrimary,
fontSize: 15,
fontWeight: '600',
opacity: 0.85,
},
});

View File

@@ -1,128 +0,0 @@
import WidgetKit
import SwiftUI
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
completion(SimpleEntry(date: Date()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
// V1
let entry = SimpleEntry(date: Date())
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date().addingTimeInterval(60 * 60 * 24 * 7)
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
}
struct MindfulnessWidgetEntryView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var family
private let title = "正念"
private let text = "你已经很努力了,今天也值得被温柔对待。"
private let deepLink = URL(string: "client:///(app)/home")
var body: some View {
switch family {
case .systemSmall:
smallView()
case .systemMedium:
mediumView()
case .systemLarge:
largeView()
default:
smallView()
}
}
private func smallView() -> some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.15, green: 0.18, blue: 0.26)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline).foregroundStyle(.white)
Text(text)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.92))
.lineLimit(4)
Spacer(minLength: 0)
}
.padding(14)
}
.widgetURL(deepLink)
}
private func mediumView() -> some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.10, green: 0.12, blue: 0.18)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline).foregroundStyle(.white)
Text(text)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.92))
.lineLimit(5)
Spacer(minLength: 0)
}
Spacer(minLength: 0)
}
.padding(16)
}
.widgetURL(deepLink)
}
private func largeView() -> some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.17, green: 0.22, blue: 0.32)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.title3)
.foregroundStyle(.white)
.bold()
Text(text)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.92))
.lineLimit(7)
Spacer(minLength: 0)
Text("轻轻呼吸,回到当下")
.font(.footnote)
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(18)
}
.widgetURL(deepLink)
}
}
struct MindfulnessWidget: Widget {
let kind: String = "MindfulnessWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
MindfulnessWidgetEntryView(entry: entry)
}
.configurationDisplayName("正念")
.description("一段温柔提醒,陪你回到当下。")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}

View File

@@ -1,12 +0,0 @@
import WidgetKit
import SwiftUI
// Widget Extension @main
@main
struct MindfulnessWidgetBundle: WidgetBundle {
var body: some Widget {
MindfulnessWidget()
EmotionWidget()
}
}

View File

@@ -1,12 +0,0 @@
# MindfulnessWidgetWidgetKit 扩展骨架)
本目录提供 iOS WidgetV1 写死文案)的 SwiftUI 代码骨架。
注意:**仅把文件放进仓库还不够**,你还需要在 Xcode 中创建 Widget Extension target并把这些文件加入 target。
## 目标
- 支持 Small/Medium/Large 三种尺寸
- 展示写死文案
- 点击小组件跳转到 App 的 Home`client:///(app)/home`

View File

@@ -67,5 +67,84 @@ target 'client' do
build_config.build_settings['DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT'] = 'YES'
end
end
# 修复Xcode 编译阶段找不到 Expo 相关 modulemap
# 现象PrecompileSwiftBridgingHeader 报错 module map file '.../Build/Products/.../Expo/Expo.modulemap' not found
# 原因Pods-client 的 xcconfig 把 -fmodule-map-file 指向了 ${PODS_CONFIGURATION_BUILD_DIR},但该文件在构建早期并不存在。
# 方案:将这些 modulemap 路径改为 Pods 内稳定存在的 Target Support Files 路径。
def patch_pods_client_xcconfig!(path)
return unless File.exist?(path)
s = File.read(path)
# expo-dev-* 的 modulemap 文件名与 module 名不同,需要单独映射
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.modulemap',
'${PODS_ROOT}/Target Support Files/expo-dev-launcher/expo-dev-launcher.modulemap')
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.modulemap',
'${PODS_ROOT}/Target Support Files/expo-dev-menu/expo-dev-menu.modulemap')
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu-interface/EXDevMenuInterface.modulemap',
'${PODS_ROOT}/Target Support Files/expo-dev-menu-interface/expo-dev-menu-interface.modulemap')
# 通用映射:${PODS_CONFIGURATION_BUILD_DIR}/<Pod>/<Pod>.modulemap -> ${PODS_ROOT}/Target Support Files/<Pod>/<Pod>.modulemap
s = s.gsub(/\$\{PODS_CONFIGURATION_BUILD_DIR\}\/([^\/]+)\/\1\.modulemap/,
'${PODS_ROOT}/Target Support Files/\1/\1.modulemap')
File.write(path, s)
end
support_dir = File.join(__dir__, 'Pods', 'Target Support Files', 'Pods-client')
patch_pods_client_xcconfig!(File.join(support_dir, 'Pods-client.debug.xcconfig'))
patch_pods_client_xcconfig!(File.join(support_dir, 'Pods-client.release.xcconfig'))
# 修复:缺失 [CP] Copy XCFrameworks 阶段时React/Expo 的 XCFramework 中间产物不会生成,
# 导致 Swift 报 no such module 'React' 等。
# 方案:在 [CP] Embed Pods Frameworks 脚本中,先执行各个 *-xcframeworks.sh 生成中间产物。
def patch_pods_client_frameworks_sh!(path)
return unless File.exist?(path)
s = File.read(path)
marker = "# [Mindfulness Fix] Prepare XCFramework intermediates\n"
return if s.include?(marker)
insert = marker +
"if [ -r \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\"\n" \
"fi\n\n"
s = s.sub(/^if \[\[ \"\$CONFIGURATION\" == \"Debug\" \]\]; then\n/, insert + "if [[ \"$CONFIGURATION\" == \"Debug\" ]]; then\n")
File.write(path, s)
end
patch_pods_client_frameworks_sh!(File.join(support_dir, 'Pods-client-frameworks.sh'))
# 让 React/ReactNativeDependencies/hermes 的 XCFramework 切片在编译 Swift 之前就准备好,
# 否则会在 AppDelegate.swift 的 `import React` 阶段报 no such module。
def patch_expo_configure_project_sh!(path)
return unless File.exist?(path)
s = File.read(path)
marker = "# [Mindfulness Fix] Prepare XCFramework intermediates (before Swift compile)\n"
return if s.include?(marker)
insert = marker +
"if [ -r \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\"\n" \
"fi\n\n"
# 插在首次调用 with_node 之前即可(不能用 ^,因为 with_node 不在文件开头)
s = s.sub("with_node \\\n", insert + "with_node \\\n")
File.write(path, s)
end
patch_expo_configure_project_sh!(File.join(support_dir, 'expo-configure-project.sh'))
end
end

View File

@@ -3,6 +3,9 @@ PODS:
- ExpoModulesCore
- EXConstants (18.0.13):
- ExpoModulesCore
- EXJSONUtils (0.15.0)
- EXManifests (1.0.10):
- ExpoModulesCore
- EXNotifications (0.32.16):
- ExpoModulesCore
- Expo (54.0.32):
@@ -30,8 +33,181 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-client (6.0.20):
- EXManifests
- expo-dev-launcher
- expo-dev-menu
- expo-dev-menu-interface
- EXUpdatesInterface
- expo-dev-launcher (6.0.20):
- EXManifests
- expo-dev-launcher/Main (= 6.0.20)
- expo-dev-menu
- expo-dev-menu-interface
- ExpoModulesCore
- EXUpdatesInterface
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-jsinspector
- React-NativeModulesApple
- React-RCTAppDelegate
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactAppDependencyProvider
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-launcher/Main (6.0.20):
- EXManifests
- expo-dev-launcher/Unsafe
- expo-dev-menu
- expo-dev-menu-interface
- ExpoModulesCore
- EXUpdatesInterface
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-jsinspector
- React-NativeModulesApple
- React-RCTAppDelegate
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactAppDependencyProvider
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-launcher/Unsafe (6.0.20):
- EXManifests
- expo-dev-menu
- expo-dev-menu-interface
- ExpoModulesCore
- EXUpdatesInterface
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-jsinspector
- React-NativeModulesApple
- React-RCTAppDelegate
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactAppDependencyProvider
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-menu (7.0.18):
- expo-dev-menu/Main (= 7.0.18)
- expo-dev-menu/ReactNativeCompatibles (= 7.0.18)
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-menu-interface (2.0.0)
- expo-dev-menu/Main (7.0.18):
- EXManifests
- expo-dev-menu-interface
- ExpoModulesCore
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-jsinspector
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-menu/ReactNativeCompatibles (7.0.18):
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- ExpoAsset (12.0.12):
- ExpoModulesCore
- ExpoCrypto (15.0.8):
- ExpoModulesCore
- ExpoFileSystem (19.0.21):
- ExpoModulesCore
- ExpoFont (14.0.11):
@@ -74,6 +250,8 @@ PODS:
- ExpoModulesCore
- ExpoWebBrowser (15.0.10):
- ExpoModulesCore
- EXUpdatesInterface (2.0.0):
- ExpoModulesCore
- FBLazyVector (0.81.5)
- hermes-engine (0.81.5):
- hermes-engine/Pre-built (= 0.81.5)
@@ -1798,6 +1976,28 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNGestureHandler (2.30.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNReanimated (4.1.6):
- hermes-engine
- RCTRequired
@@ -2040,12 +2240,19 @@ PODS:
DEPENDENCIES:
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)"
- "EXConstants (from `../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)"
- "EXJSONUtils (from `../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)"
- "EXManifests (from `../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios`)"
- "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios`)"
- "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo`)"
- "expo-dev-client (from `../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios`)"
- "expo-dev-launcher (from `../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher`)"
- "expo-dev-menu (from `../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu`)"
- "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)"
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
- "ExpoCrypto (from `../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios`)"
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
- "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)"
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios`)"
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios`)"
- "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)"
- "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios`)"
- "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)"
@@ -2053,6 +2260,7 @@ DEPENDENCIES:
- "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)"
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)"
- "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios`)"
- "EXUpdatesInterface (from `../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios`)"
- "FBLazyVector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector`)"
- "hermes-engine (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)"
- "RCTDeprecation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)"
@@ -2122,6 +2330,7 @@ DEPENDENCIES:
- "ReactCommon/turbomodule/core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
- "ReactNativeDependencies (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)"
- "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage`)"
- "RNGestureHandler (from `../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1._tylda4qoo2jtxaj3472gn4luma/node_modules/react-native-gesture-handler`)"
- "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated`)"
- "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)"
- "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)"
@@ -2133,18 +2342,32 @@ EXTERNAL SOURCES:
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios"
EXConstants:
:path: "../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios"
EXJSONUtils:
:path: "../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios"
EXManifests:
:path: "../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios"
EXNotifications:
:path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios"
Expo:
:path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo"
expo-dev-client:
:path: "../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios"
expo-dev-launcher:
:path: "../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher"
expo-dev-menu:
:path: "../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu"
expo-dev-menu-interface:
:path: "../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios"
ExpoAsset:
:path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios"
ExpoCrypto:
:path: "../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios"
ExpoFileSystem:
:path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios"
ExpoFont:
:path: "../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios"
ExpoHead:
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios"
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios"
ExpoKeepAwake:
:path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios"
ExpoLinearGradient:
@@ -2159,6 +2382,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios"
ExpoWebBrowser:
:path: "../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios"
EXUpdatesInterface:
:path: "../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios"
FBLazyVector:
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector"
hermes-engine:
@@ -2296,6 +2521,8 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
RNCAsyncStorage:
:path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage"
RNGestureHandler:
:path: "../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1._tylda4qoo2jtxaj3472gn4luma/node_modules/react-native-gesture-handler"
RNReanimated:
:path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated"
RNScreens:
@@ -2310,9 +2537,16 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
EXManifests: 83ef0844fcf06d6099b12a7bdbd7d36fc0e1dd16
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
expo-dev-client: 12ef7d5b14d93e309922acea78dcd851db583a87
expo-dev-launcher: 47994056008ffdc30a6a5e328a375b3e30a8db05
expo-dev-menu: ea4fb803ace52e60d7cd8060c7cd379612a140b2
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
@@ -2323,6 +2557,7 @@ SPEC CHECKSUMS:
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
@@ -2391,12 +2626,13 @@ SPEC CHECKSUMS:
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
PODFILE CHECKSUM: 4d5c52f9fa870c1d398cf59e37c149f66700c061
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
COCOAPODS: 1.16.2

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 70;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -11,7 +11,10 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; };
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; };
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
@@ -50,7 +53,9 @@
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
@@ -58,7 +63,8 @@
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "情绪小组件Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindfulnessWidget.swift; sourceTree = "<group>"; };
EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = clientRelease.entitlements; path = client/clientRelease.entitlements; sourceTree = "<group>"; };
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "情绪小组件ExtensionRelease.entitlements"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = client/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* client-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "client-Bridging-Header.h"; path = "client/client-Bridging-Header.h"; sourceTree = "<group>"; };
@@ -66,7 +72,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
EmotionWidget.swift,
@@ -77,7 +83,18 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "情绪小组件";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -86,6 +103,7 @@
buildActionMask = 2147483647;
files = (
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */,
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -104,8 +122,10 @@
13B07FAE1A68108700A75B9A /* client */ = {
isa = PBXGroup;
children = (
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */,
EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */,
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */,
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */,
F11748442D0722820044C1D9 /* client-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
@@ -145,6 +165,7 @@
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */,
13B07FAE1A68108700A75B9A /* client */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
@@ -189,7 +210,7 @@
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@@ -367,6 +388,8 @@
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
@@ -380,6 +403,8 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
@@ -441,6 +466,8 @@
files = (
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */,
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */,
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -448,7 +475,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -473,6 +500,7 @@
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
CURRENT_PROJECT_VERSION = 4;
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
@@ -492,7 +520,7 @@
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
PRODUCT_NAME = HeyMama;
SKIP_INSTALL = YES;
SKIP_INSTALL = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
@@ -512,11 +540,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
CODE_SIGN_ENTITLEMENTS = client/clientRelease.entitlements;
CURRENT_PROJECT_VERSION = 4;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = WS92GPX9H2;
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
INFOPLIST_FILE = client/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
@@ -532,7 +561,7 @@
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
PRODUCT_NAME = HeyMama;
SKIP_INSTALL = YES;
SKIP_INSTALL = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
@@ -734,6 +763,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 4;

View File

@@ -3,7 +3,7 @@
LastUpgradeVersion = "2620"
version = "2.2">
<BuildAction
parallelizeBuildables = "YES"
parallelizeBuildables = "NO"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
@@ -15,6 +15,20 @@
<AutocreatedTestPlanReference>
</AutocreatedTestPlanReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E99E52E7F84CEF53B06494B581AFB6E4"
BuildableName = "libPods-client.a"
BlueprintName = "Pods-client"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"

View File

@@ -0,0 +1,57 @@
import Foundation
import React
import WidgetKit
/**
* App Group RN Bridge
*
*
* - suiteNamegroup.com.damer.mindfulness App Widget Extension entitlements
* - 使 JSON JS
*/
@objc(AppGroupStorage)
final class AppGroupStorage: NSObject, RCTBridgeModule {
static func moduleName() -> String! {
"AppGroupStorage"
}
static func requiresMainQueueSetup() -> Bool {
false
}
private let suiteName = "group.com.damer.mindfulness"
private func defaults() -> UserDefaults? {
UserDefaults(suiteName: suiteName)
}
@objc(setString:value:resolver:rejecter:)
func setString(_ key: String, value: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
guard let d = defaults() else {
reject("E_APP_GROUP", "无法初始化 App Group UserDefaultssuiteName=\(suiteName)", nil)
return
}
d.set(value, forKey: key)
resolve(nil)
}
@objc(getString:resolver:rejecter:)
func getString(_ key: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
guard let d = defaults() else {
reject("E_APP_GROUP", "无法初始化 App Group UserDefaultssuiteName=\(suiteName)", nil)
return
}
let v = d.string(forKey: key)
resolve(v)
}
/**
* Widget
*/
@objc(reloadAllTimelines:rejecter:)
func reloadAllTimelines(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
WidgetCenter.shared.reloadAllTimelines()
resolve(nil)
}
}

View File

@@ -0,0 +1,26 @@
#import <React/RCTBridgeModule.h>
/**
* Swift
*
*
* - React Native Swift RCT_EXTERN_MODULE / RCT_EXTERN_METHOD
* - JS NativeModules.AppGroupStorage undefined
*/
@interface RCT_EXTERN_MODULE(AppGroupStorage, NSObject)
RCT_EXTERN_METHOD(setString:(NSString *)key
value:(NSString *)value
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getString:(NSString *)key
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(reloadAllTimelines:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end

View File

@@ -1,3 +1,10 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
// 说明:
// - 部分环境下仅 `import React` 可能无法在 Swift 中解析到 RCTBridge 等类型
// - 通过 Bridging Header 显式引入需要的 React 头文件,保证 AppDelegate.swift 可编译
#import <React/RCTBridge.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>

View File

@@ -4,5 +4,10 @@
<dict>
<key>aps-environment</key>
<string>production</string>
<!-- iOS 小组件需要通过 App Group 与主 App 共享数据 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damer.mindfulness</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damer.mindfulness</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
# 修复 Xcode Organizer 显示 “Generic Xcode Archive” 的问题:
# - 某些情况下 xcodebuild 生成的 .xcarchive/Info.plist 缺少 ApplicationProperties
# - Organizer 无法识别归档中的主 App即使 Products/Applications/*.app 存在)
#
# 用法:
# ./scripts/fix-xcarchive-header.sh "/path/to/xxx.xcarchive"
ARCHIVE_PATH="${1:-}"
if [[ -z "$ARCHIVE_PATH" ]]; then
echo "用法: $0 \"/path/to/xxx.xcarchive\"" >&2
exit 2
fi
if [[ ! -d "$ARCHIVE_PATH" ]]; then
echo "错误:找不到归档目录:$ARCHIVE_PATH" >&2
exit 2
fi
ARCHIVE_INFO_PLIST="$ARCHIVE_PATH/Info.plist"
if [[ ! -f "$ARCHIVE_INFO_PLIST" ]]; then
echo "错误:找不到归档 Info.plist$ARCHIVE_INFO_PLIST" >&2
exit 2
fi
# 取第一个 App归档里通常只有一个主 App
APP_PLIST="$(/usr/bin/find "$ARCHIVE_PATH/Products/Applications" -maxdepth 2 -name Info.plist -path "*.app/Info.plist" 2>/dev/null | /usr/bin/head -n 1 || true)"
if [[ -z "$APP_PLIST" ]]; then
echo "错误:归档中未找到 Products/Applications/*.app/Info.plist请先确保归档产出包含 .app" >&2
exit 2
fi
APP_DIR="$(/usr/bin/dirname "$APP_PLIST")"
APP_NAME="$(/usr/bin/basename "$APP_DIR")" # 例如 HeyMama.app
APP_REL_PATH="Applications/$APP_NAME"
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"
short_version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$APP_PLIST" 2>/dev/null || true)"
build_version="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$APP_PLIST" 2>/dev/null || true)"
if [[ -z "$bundle_id" || -z "$short_version" || -z "$build_version" ]]; then
echo "错误:无法从 App Info.plist 读取 bundle/version/build$APP_PLIST" >&2
exit 2
fi
# 解析 embedded.mobileprovision若存在
profile_name=""
profile_uuid=""
team_id=""
provision_path="$APP_DIR/embedded.mobileprovision"
if [[ -f "$provision_path" ]]; then
decoded="$(/usr/bin/security cms -D -i "$provision_path" 2>/dev/null || true)"
if [[ -n "$decoded" ]]; then
# 使用 plutil 从 xml 中提取字段
profile_name="$(printf "%s" "$decoded" | /usr/bin/plutil -extract Name raw -o - - 2>/dev/null || true)"
profile_uuid="$(printf "%s" "$decoded" | /usr/bin/plutil -extract UUID raw -o - - 2>/dev/null || true)"
team_id="$(printf "%s" "$decoded" | /usr/bin/plutil -extract TeamIdentifier.0 raw -o - - 2>/dev/null || true)"
fi
fi
# 备份一份,防止误操作
cp -f "$ARCHIVE_INFO_PLIST" "$ARCHIVE_INFO_PLIST.bak"
# 如果已有 ApplicationProperties直接更新关键字段即可
if /usr/bin/plutil -extract ApplicationProperties xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
/usr/bin/plutil -replace ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -replace ApplicationProperties.CFBundleIdentifier -string "$bundle_id" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -replace ApplicationProperties.CFBundleShortVersionString -string "$short_version" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -replace ApplicationProperties.CFBundleVersion -string "$build_version" "$ARCHIVE_INFO_PLIST"
else
# 新增 ApplicationProperties注意plutil 的空字典/数组类型是 -dictionary / -array
/usr/bin/plutil -insert ApplicationProperties -dictionary "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.CFBundleIdentifier -string "$bundle_id" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.CFBundleShortVersionString -string "$short_version" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.CFBundleVersion -string "$build_version" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.Architectures -array "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.Architectures.0 -string "arm64" "$ARCHIVE_INFO_PLIST"
fi
# 可选字段Provisioning Profile 信息(不保证一定存在)
if [[ -n "$profile_name" ]]; then
/usr/bin/plutil -replace ApplicationProperties.ProvisioningProfileName -string "$profile_name" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
/usr/bin/plutil -insert ApplicationProperties.ProvisioningProfileName -string "$profile_name" "$ARCHIVE_INFO_PLIST"
fi
if [[ -n "$profile_uuid" ]]; then
/usr/bin/plutil -replace ApplicationProperties.ProvisioningProfileUUID -string "$profile_uuid" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
/usr/bin/plutil -insert ApplicationProperties.ProvisioningProfileUUID -string "$profile_uuid" "$ARCHIVE_INFO_PLIST"
fi
if [[ -n "$team_id" ]]; then
/usr/bin/plutil -replace ApplicationProperties.Team -string "$team_id" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
/usr/bin/plutil -insert ApplicationProperties.Team -string "$team_id" "$ARCHIVE_INFO_PLIST"
fi
echo "已修复归档 header$ARCHIVE_INFO_PLIST"
echo "主 App$APP_REL_PATH"
echo "Bundle$bundle_id"
echo "Version/Build$short_version/$build_version"

View File

@@ -1,49 +1,261 @@
import Foundation
import WidgetKit
import SwiftUI
// V1Small/Medium/Large + Home
// Daily Widget Reco App Group /v1/reco/widget
private let appGroupSuiteName = "group.com.damer.mindfulness"
private let keyWidgetConfig = "widget.config.v1"
private let keyWidgetUserProfile = "widget.userProfile.v1_2"
private let keyWidgetDailyReco = "widget.dailyReco.v1"
private let fallbackTextTC = "你已经很努力了,今天也值得被温柔对待。"
private let fallbackTextEN = "Youve been doing great — you deserve kindness today."
private func defaults() -> UserDefaults? {
UserDefaults(suiteName: appGroupSuiteName)
}
private func isoNow() -> String {
ISO8601DateFormatter().string(from: Date())
}
private func localDayKey(_ date: Date = Date()) -> String {
let fmt = DateFormatter()
fmt.calendar = Calendar.current
fmt.timeZone = TimeZone.current
fmt.dateFormat = "yyyy-MM-dd"
return fmt.string(from: date)
}
private func resolveLang() -> String {
// en/tc
let preferred = Locale.preferredLanguages.first?.lowercased() ?? "en"
return preferred.hasPrefix("zh") ? "tc" : "en"
}
private func resolveTitle(lang: String) -> String {
lang == "en" ? "Mindfulness" : "正念"
}
private func resolveFooterHint(lang: String) -> String {
lang == "en" ? "Tap to open the app" : "点我回到 App"
}
private func joinUrl(base: String, path: String) -> String {
let b = base.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
if path.hasPrefix("/") { return "\(b)\(path)" }
return "\(b)/\(path)"
}
private func nextDailyRefreshDate(from now: Date) -> Date {
// 00:1001:00
var cal = Calendar.current
cal.timeZone = TimeZone.current
guard let tomorrow = cal.date(byAdding: .day, value: 1, to: now) else {
return now.addingTimeInterval(60 * 60 * 6)
}
let start = cal.startOfDay(for: tomorrow)
let minDate = cal.date(byAdding: .minute, value: 10, to: start) ?? start.addingTimeInterval(60 * 10)
let maxDate = cal.date(byAdding: .hour, value: 1, to: start) ?? start.addingTimeInterval(60 * 60)
let interval = max(0, maxDate.timeIntervalSince(minDate))
let jitter = interval > 0 ? Double.random(in: 0..<interval) : 0
return minDate.addingTimeInterval(jitter)
}
private func readJsonDict(forKey key: String) -> [String: Any]? {
guard let raw = defaults()?.string(forKey: key) else { return nil }
guard let data = raw.data(using: .utf8) else { return nil }
let obj = try? JSONSerialization.jsonObject(with: data, options: [])
return obj as? [String: Any]
}
private func writeJsonDict(_ dict: [String: Any], forKey key: String) {
guard let data = try? JSONSerialization.data(withJSONObject: dict, options: []) else { return }
guard let raw = String(data: data, encoding: .utf8) else { return }
defaults()?.set(raw, forKey: key)
}
private func readCachedText() -> (dayKey: String?, lang: String, text: String)? {
guard let d = readJsonDict(forKey: keyWidgetDailyReco) else { return nil }
let lang = (d["lang"] as? String) ?? resolveLang()
let dayKey = d["day_key"] as? String
if let item = d["item"] as? [String: Any], let text = item["text"] as? String, !text.isEmpty {
return (dayKey: dayKey, lang: lang, text: text)
}
return nil
}
private func readApiBaseUrl() -> String? {
guard let d = readJsonDict(forKey: keyWidgetConfig) else { return nil }
let base = d["apiBaseUrl"] as? String
return base?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func readUserProfileDict() -> [String: Any]? {
guard let d = readJsonDict(forKey: keyWidgetUserProfile) else { return nil }
return d["user_profile"] as? [String: Any]
}
private func saveDailyReco(lang: String, dayKey: String, contentId: Int, text: String, meta: [String: Any]?) {
var dict: [String: Any] = [
"schema_version": 1,
"saved_at": isoNow(),
"day_key": dayKey,
"lang": lang,
"source": "widget",
"item": [
"content_id": contentId,
"text": text
]
]
if let meta = meta { dict["meta"] = meta }
writeJsonDict(dict, forKey: keyWidgetDailyReco)
}
private func fetchDailyRecoFromServer() async -> (lang: String, text: String, contentId: Int, meta: [String: Any]?)? {
guard let baseUrl = readApiBaseUrl(), !baseUrl.isEmpty else { return nil }
guard let userProfile = readUserProfileDict() else { return nil }
let lang = resolveLang()
let urlStr = joinUrl(base: baseUrl, path: "/v1/reco/widget")
guard let url = URL(string: urlStr) else { return nil }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.timeoutInterval = 12
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(lang, forHTTPHeaderField: "Accept-Language")
let body: [String: Any] = [
"k": 1,
"user_profile": userProfile,
"already_recommended_ids": [],
"touched_or_viewed_ids": []
]
req.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
do {
let (data, res) = try await URLSession.shared.data(for: req)
guard let httpRes = res as? HTTPURLResponse, (200..<300).contains(httpRes.statusCode) else { return nil }
let obj = try JSONSerialization.jsonObject(with: data, options: [])
guard let root = obj as? [String: Any] else { return nil }
guard let items = root["items"] as? [[String: Any]], let first = items.first else { return nil }
guard let text = first["text"] as? String, !text.isEmpty else { return nil }
let contentId = (first["content_id"] as? Int) ?? Int((first["content_id"] as? NSNumber)?.intValue ?? -1)
if contentId < 0 { return nil }
let meta = root["meta"] as? [String: Any]
return (lang: lang, text: text, contentId: contentId, meta: meta)
} catch {
return nil
}
}
struct EmotionProvider: TimelineProvider {
func placeholder(in context: Context) -> EmotionEntry {
EmotionEntry(date: Date())
let lang = resolveLang()
return EmotionEntry(
date: Date(),
lang: lang,
title: resolveTitle(lang: lang),
text: lang == "en" ? fallbackTextEN : fallbackTextTC,
footerHint: resolveFooterHint(lang: lang)
)
}
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
completion(EmotionEntry(date: Date()))
completion(placeholder(in: context))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
// V1
let entry = EmotionEntry(date: Date())
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date())
?? Date().addingTimeInterval(60 * 60 * 24 * 7)
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
Task {
let lang = resolveLang()
let today = localDayKey(Date())
// 1)
if let cached = readCachedText(), cached.dayKey == today {
let entry = EmotionEntry(
date: Date(),
lang: cached.lang,
title: resolveTitle(lang: cached.lang),
text: cached.text,
footerHint: resolveFooterHint(lang: cached.lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
return
}
// 2) /
if let fetched = await fetchDailyRecoFromServer() {
saveDailyReco(lang: fetched.lang, dayKey: today, contentId: fetched.contentId, text: fetched.text, meta: fetched.meta)
let entry = EmotionEntry(
date: Date(),
lang: fetched.lang,
title: resolveTitle(lang: fetched.lang),
text: fetched.text,
footerHint: resolveFooterHint(lang: fetched.lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
return
}
// 3)
if let cached = readCachedText() {
let entry = EmotionEntry(
date: Date(),
lang: cached.lang,
title: resolveTitle(lang: cached.lang),
text: cached.text,
footerHint: resolveFooterHint(lang: cached.lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
return
}
let entry = EmotionEntry(
date: Date(),
lang: lang,
title: resolveTitle(lang: lang),
text: lang == "en" ? fallbackTextEN : fallbackTextTC,
footerHint: resolveFooterHint(lang: lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
}
}
}
struct EmotionEntry: TimelineEntry {
let date: Date
let lang: String
let title: String
let text: String
let footerHint: String
}
struct EmotionWidgetView: View {
var entry: EmotionProvider.Entry
@Environment(\.widgetFamily) var family
private let title = "正念"
private let text = "你已经很努力了,今天也值得被温柔对待。"
private let deepLink = URL(string: "client:///(app)/home")
var body: some View {
switch family {
case .systemSmall:
smallView()
case .systemMedium:
mediumView()
case .systemLarge:
largeView()
default:
smallView()
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.14, green: 0.18, blue: 0.28),
])
// //
Text(entry.text)
.font(fontForFamily())
.foregroundColor(Color.white.opacity(0.92))
.multilineTextAlignment(.leading)
.lineSpacing(lineSpacingForFamily())
.lineLimit(lineLimitForFamily())
.minimumScaleFactor(0.78)
.padding(paddingForFamily())
}
.widgetURL(deepLink)
}
// iOS 15
@@ -69,122 +281,52 @@ struct EmotionWidgetView: View {
.cornerRadius(18)
}
private func chip(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.9))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.14))
.cornerRadius(999)
}
private func smallView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.13, green: 0.16, blue: 0.22),
])
VStack(alignment: .leading, spacing: 10) {
HStack {
chip(title)
Spacer(minLength: 0)
}
Text(text)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(2)
.lineLimit(4)
Spacer(minLength: 0)
Text("点我回到 App")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color.white.opacity(0.65))
}
.padding(14)
}
.widgetURL(deepLink)
}
private func mediumView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.09, green: 0.11, blue: 0.17),
])
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 10) {
chip(title)
Text(text)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(3)
.lineLimit(5)
Spacer(minLength: 0)
Text("轻轻呼吸,回到当下")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
}
//
VStack(alignment: .trailing, spacing: 8) {
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.8))
Spacer(minLength: 0)
Text("今日")
.font(.system(size: 28, weight: .bold))
.foregroundColor(Color.white.opacity(0.12))
private func fontForFamily() -> Font {
switch family {
case .systemSmall:
return .system(size: 16, weight: .semibold)
case .systemMedium:
return .system(size: 18, weight: .semibold)
case .systemLarge:
return .system(size: 22, weight: .semibold)
default:
return .system(size: 16, weight: .semibold)
}
}
.padding(16)
private func lineSpacingForFamily() -> CGFloat {
switch family {
case .systemLarge:
return 4
default:
return 3
}
.widgetURL(deepLink)
}
private func largeView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.14, green: 0.18, blue: 0.28),
])
VStack(alignment: .leading, spacing: 14) {
HStack {
chip(title)
Spacer(minLength: 0)
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.78))
}
Text(text)
.font(.system(size: 20, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(4)
.lineLimit(8)
Spacer(minLength: 0)
HStack {
Text("点我回到 Home")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
Spacer(minLength: 0)
Text("🌿")
.font(.system(size: 18))
.opacity(0.9)
private func lineLimitForFamily() -> Int {
switch family {
case .systemSmall:
return 5
case .systemMedium:
return 6
case .systemLarge:
return 8
default:
return 5
}
}
.padding(18)
private func paddingForFamily() -> CGFloat {
switch family {
case .systemSmall:
return 14
case .systemMedium:
return 16
case .systemLarge:
return 18
default:
return 14
}
.widgetURL(deepLink)
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damer.mindfulness</string>
</array>
</dict>
</plist>

View File

@@ -5,7 +5,7 @@
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"ios": "expo run:ios --scheme \"Hey Mama\"",
"web": "expo start --web",
"test": "vitest run"
},
@@ -15,7 +15,9 @@
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.32",
"expo-constants": "~18.0.13",
"expo-crypto": "^15.0.8",
"expo-dev-client": "^6.0.20",
"expo-device": "^8.0.10",
"expo-font": "~14.0.11",
"expo-linear-gradient": "^15.0.8",
"expo-linking": "~8.0.11",

32
client/pnpm-lock.yaml generated
View File

@@ -23,9 +23,15 @@ importers:
expo-constants:
specifier: ~18.0.13
version: 18.0.13(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))
expo-crypto:
specifier: ^15.0.8
version: 15.0.8(expo@54.0.32)
expo-dev-client:
specifier: ^6.0.20
version: 6.0.20(expo@54.0.32)
expo-device:
specifier: ^8.0.10
version: 8.0.10(expo@54.0.32)
expo-font:
specifier: ~14.0.11
version: 14.0.11(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -2229,6 +2235,11 @@ packages:
expo: '*'
react-native: '*'
expo-crypto@15.0.8:
resolution: {integrity: sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==}
peerDependencies:
expo: '*'
expo-dev-client@6.0.20:
resolution: {integrity: sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==}
peerDependencies:
@@ -2249,6 +2260,11 @@ packages:
peerDependencies:
expo: '*'
expo-device@8.0.10:
resolution: {integrity: sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==}
peerDependencies:
expo: '*'
expo-file-system@19.0.21:
resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==}
peerDependencies:
@@ -3811,6 +3827,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ua-parser-js@0.7.41:
resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
hasBin: true
ua-parser-js@1.0.41:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
@@ -6537,6 +6557,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
expo-crypto@15.0.8(expo@54.0.32):
dependencies:
base64-js: 1.5.1
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-dev-client@6.0.20(expo@54.0.32):
dependencies:
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -6566,6 +6591,11 @@ snapshots:
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-dev-menu-interface: 2.0.0(expo@54.0.32)
expo-device@8.0.10(expo@54.0.32):
dependencies:
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
ua-parser-js: 0.7.41
expo-file-system@19.0.21(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)):
dependencies:
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -8282,6 +8312,8 @@ snapshots:
typescript@5.9.3: {}
ua-parser-js@0.7.41: {}
ua-parser-js@1.0.41: {}
undici-types@7.16.0: {}

View File

@@ -45,8 +45,10 @@ export const API_BASE_URL = getApiBaseUrl(APP_ENV);
* 调试:打印环境变量注入结果(仅开发环境)
*
* 用途:排查「为什么 API_BASE_URL 不是预期值」的问题(例如 .env.local/命令行注入/缓存导致)。
*
* 注意:在某些测试环境(如 vitest里 `__DEV__` 可能不存在,需做兼容判断。
*/
if (__DEV__) {
if (typeof __DEV__ !== 'undefined' && __DEV__) {
const injected = {
EXPO_PUBLIC_ENV: process.env.EXPO_PUBLIC_ENV,
EXPO_PUBLIC_API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL,

View File

@@ -30,7 +30,7 @@
"later": "稍后",
"loading": "处理中…",
"errorTitle": "提示",
"errorDesc": "开启失败也没关系,你仍然可以继续使用应用。"
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token建议用真机测试。"
},
"home": {
"title": "正念",

View File

@@ -0,0 +1,173 @@
import { API_BASE_URL } from '@/src/constants/env';
import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types';
import { fetchRecoWidget } from '@/src/services/recoApi';
import i18n from 'i18next';
import { getUserProfileScoring } from '@/src/storage/appStorage';
import { getLocalDayKey } from '@/src/utils/date';
import {
appGroupGetString,
appGroupReloadAllTimelines,
appGroupSetString,
isAppGroupStorageAvailable,
} from '@/src/services/appGroupStorage';
/**
* App Group 共享 keyApp ↔ Widget 共通)
*/
export const WIDGET_CONFIG_KEY = 'widget.config.v1';
export const WIDGET_USER_PROFILE_KEY = 'widget.userProfile.v1_2';
export const WIDGET_DAILY_RECO_KEY = 'widget.dailyReco.v1';
export type WidgetConfigV1 = {
schema_version: 1;
saved_at: string; // ISO8601
apiBaseUrl: string;
};
export type WidgetUserProfileV1_2 = {
schema_version: 1;
saved_at: string; // ISO8601
user_profile: UserProfileV1_2;
};
export type WidgetDailyRecoV1 = {
schema_version: 1;
saved_at: string; // ISO8601
day_key: string; // YYYY-MM-DD用户时区
lang: 'en' | 'tc';
item: null | {
content_id: number;
text: string;
final_score?: number;
fallback_level_final?: number;
};
meta?: Record<string, unknown>;
source?: 'app' | 'widget';
};
function safeJsonParse<T>(raw: string | null): T | null {
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function pickUserProfileV1_2(scoringProfile: UserProfileV1_2_Extended): UserProfileV1_2 {
return {
profile_version: scoringProfile.profile_version,
profile_source: scoringProfile.profile_source,
profile_generated_at: scoringProfile.profile_generated_at,
profile_confidence: scoringProfile.profile_confidence,
profile_answered: scoringProfile.profile_answered,
stage: scoringProfile.stage,
emotion_score: scoringProfile.emotion_score,
context: scoringProfile.context,
need: scoringProfile.need,
};
}
export async function syncWidgetConfig(): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
const payload: WidgetConfigV1 = {
schema_version: 1,
saved_at: new Date().toISOString(),
apiBaseUrl: API_BASE_URL,
};
await appGroupSetString(WIDGET_CONFIG_KEY, JSON.stringify(payload));
}
export async function syncWidgetUserProfileFromScoring(scoringProfile: UserProfileV1_2_Extended): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
const payload: WidgetUserProfileV1_2 = {
schema_version: 1,
saved_at: new Date().toISOString(),
user_profile: pickUserProfileV1_2(scoringProfile),
};
await appGroupSetString(WIDGET_USER_PROFILE_KEY, JSON.stringify(payload));
}
export async function syncWidgetUserProfileFromStorage(): Promise<void> {
const scoringProfile = await getUserProfileScoring();
if (!scoringProfile) return;
await syncWidgetUserProfileFromScoring(scoringProfile);
}
export async function getWidgetDailyRecoCache(): Promise<WidgetDailyRecoV1 | null> {
if (!isAppGroupStorageAvailable()) return null;
const raw = await appGroupGetString(WIDGET_DAILY_RECO_KEY);
return safeJsonParse<WidgetDailyRecoV1>(raw);
}
export async function setWidgetDailyRecoCache(cache: WidgetDailyRecoV1): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
await appGroupSetString(WIDGET_DAILY_RECO_KEY, JSON.stringify(cache));
}
/**
* App 前台辅助刷新(不保证准点,但能提升更新及时性与一致性)
*
* 规则:
* - 若共享缓存 `day_key` 已是今天 → 不请求
* - 若缺少用户画像 → 不请求(交给 Widget 走兜底/下次重试)
* - 成功后写入共享缓存并触发 Widget reload系统仍可能延迟
*/
export async function ensureDailyWidgetRecoUpToDate(args?: {
reason?: string;
scoringProfile?: UserProfileV1_2_Extended | null;
}): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
// 先确保 Widget 能拿到 baseURLdev/pro 切换时很关键)
await syncWidgetConfig();
const today = getLocalDayKey(new Date());
const cached = await getWidgetDailyRecoCache();
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) return;
const scoringProfile = args?.scoringProfile ?? (await getUserProfileScoring());
if (!scoringProfile) return;
// 同步画像给 Widget保证 Widget 独立拉取也有输入)
await syncWidgetUserProfileFromScoring(scoringProfile);
try {
const { items, meta } = await fetchRecoWidget({
k: 1,
user_profile: pickUserProfileV1_2(scoringProfile),
already_recommended_ids: [],
touched_or_viewed_ids: [],
});
const top = items?.[0];
if (!top?.text) return;
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
await setWidgetDailyRecoCache({
schema_version: 1,
saved_at: new Date().toISOString(),
day_key: today,
// 语言策略:与后端 Accept-Language 保持一致(目前只区分 en/tc
lang,
item: {
content_id: top.content_id,
text: top.text,
final_score: top.final_score,
fallback_level_final: top.fallback_level_final,
},
meta: meta as Record<string, unknown>,
source: 'app',
});
// 触发 Widget 刷新(系统仍可能延迟)
await appGroupReloadAllTimelines();
} catch (e) {
// 失败不阻塞主流程Widget 将使用缓存或兜底文案
if (typeof __DEV__ !== 'undefined' && __DEV__) {
console.log('[DailyWidgetReco] App 前台刷新失败:', args?.reason ?? 'unknown', e);
}
}
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import i18n from 'i18next';
import { buildAcceptLanguage } from '../legalApi';
describe('legalApi.buildAcceptLanguage', () => {
function setLang(lang: string) {
Object.defineProperty(i18n, 'language', {
value: lang,
configurable: true,
});
}
it('非中文语言回退为 en', () => {
setLang('en');
expect(buildAcceptLanguage()).toBe('en');
setLang('es');
expect(buildAcceptLanguage()).toBe('en');
});
it('任意 zh* 归一为 tc', () => {
setLang('zh-CN');
expect(buildAcceptLanguage()).toBe('tc');
setLang('zh-TW');
expect(buildAcceptLanguage()).toBe('tc');
});
it('显式 tc / hant 等归一为 tc', () => {
setLang('tc');
expect(buildAcceptLanguage()).toBe('tc');
setLang('zh-Hant');
expect(buildAcceptLanguage()).toBe('tc');
});
});

View File

@@ -0,0 +1,53 @@
import { NativeModules, Platform } from 'react-native';
type AppGroupStorageNativeModule = {
/**
* 写入 App Group 的 UserDefaults值为字符串通常是 JSON
*/
setString(key: string, value: string): Promise<void>;
/**
* 读取 App Group 的 UserDefaults值为字符串通常是 JSON
*/
getString(key: string): Promise<string | null>;
/**
* 触发 Widget 刷新iOS 系统仍可能延迟)
*/
reloadAllTimelines(): Promise<void>;
};
function getNativeModule(): AppGroupStorageNativeModule | null {
if (Platform.OS !== 'ios') return null;
const raw = (NativeModules as Record<string, unknown>)?.AppGroupStorage as unknown;
if (!raw || typeof raw !== 'object') return null;
// 注意:若 iOS 原生模块没有正确导出方法(例如缺少 RCT_EXTERN_METHOD 桥接)
// JS 侧可能能拿到模块对象,但方法会是 undefined这里需要做运行时校验避免崩溃。
const m = raw as Partial<AppGroupStorageNativeModule>;
if (typeof m.setString !== 'function') return null;
if (typeof m.getString !== 'function') return null;
if (typeof m.reloadAllTimelines !== 'function') return null;
return m as AppGroupStorageNativeModule;
}
export function isAppGroupStorageAvailable(): boolean {
return Boolean(getNativeModule());
}
export async function appGroupSetString(key: string, value: string): Promise<void> {
const m = getNativeModule();
if (!m) return;
await m.setString(key, value);
}
export async function appGroupGetString(key: string): Promise<string | null> {
const m = getNativeModule();
if (!m) return null;
return await m.getString(key);
}
export async function appGroupReloadAllTimelines(): Promise<void> {
const m = getNativeModule();
if (!m) return;
await m.reloadAllTimelines();
}

View File

@@ -0,0 +1,37 @@
import i18n from 'i18next';
import { httpJson } from '../utils/http';
export type LegalLinks = {
privacyPolicyUrl: string;
termsOfUseUrl: string;
resolvedLang: 'en' | 'tc';
};
export function buildAcceptLanguage(): 'en' | 'tc' {
const lang = (i18n.language || '').trim();
const lower = lang.toLowerCase();
// 当前多语言仅支持 EN / TC与 reco 链路一致);其他语言统一回退到 en
if (lower.startsWith('zh')) {
return 'tc';
}
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) {
return 'tc';
}
return 'en';
}
export async function fetchLegalLinks(): Promise<LegalLinks> {
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
return await httpJson<LegalLinks>({
path: '/v1/legal/links',
method: 'GET',
headers,
timeoutMs: 8_000,
debugLabel: 'LegalLinks',
});
}

View File

@@ -0,0 +1,210 @@
import i18n from 'i18next';
import Constants from 'expo-constants';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { httpJson } from '../utils/http';
import { APP_ENV } from '../constants/env';
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
import type { UserProfileScoring } from '../storage/appStorage';
export type PushEnv = 'dev' | 'prod';
export type PushPlatform = 'ios' | 'android';
export type PushDeviceMeta = {
model?: string;
os_version?: string;
app_version?: string;
locale?: string;
timezone?: string;
};
export type PushRegisterRequest = {
client_user_id: string;
platform: PushPlatform;
push_token: string;
app_id: string;
env: PushEnv;
device_meta?: PushDeviceMeta;
};
export type PushPreferencesRequest = {
client_user_id: string;
enabled: boolean;
times_per_day: number; // 05
timezone?: string;
locale?: string;
// 可选:用户画像(用于后端 Push 场景推荐文案生成)
user_profile?: UserProfileScoring;
};
export type PushPreferencesResponse = PushPreferencesRequest & {
updated_at?: string;
};
export function buildAcceptLanguage(): 'en' | 'tc' {
const lang = (i18n.language || '').trim();
const lower = lang.toLowerCase();
if (lower.startsWith('zh')) return 'tc';
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) return 'tc';
return 'en';
}
function toPushEnv(appEnv: typeof APP_ENV): PushEnv {
// 客户端 APP_ENV: local/dev/prod → 后端推送 env: dev/prod
if (appEnv === 'prod') return 'prod';
return 'dev';
}
function pickAppId(): string {
// 优先按平台取 bundleId/package拿不到则回退到 slug
if (Platform.OS === 'ios') {
return (
Constants.expoConfig?.ios?.bundleIdentifier ||
Constants.easConfig?.projectId ||
Constants.expoConfig?.slug ||
'unknown'
);
}
if (Platform.OS === 'android') {
return (
Constants.expoConfig?.android?.package ||
Constants.easConfig?.projectId ||
Constants.expoConfig?.slug ||
'unknown'
);
}
return Constants.expoConfig?.slug || 'unknown';
}
function pickTimezone(): string | undefined {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz && String(tz).trim() ? String(tz).trim() : undefined;
} catch {
return undefined;
}
}
function pickLocale(): string | undefined {
const lang = (i18n.language || '').trim();
return lang ? lang : undefined;
}
function pickPlatform(): PushPlatform {
return Platform.OS === 'ios' ? 'ios' : 'android';
}
function getExpoProjectId(): string | undefined {
// Expo 官方推荐读取 projectIdEAS/Dev Client 下通常需要)
return (
Constants.easConfig?.projectId ||
// 兼容 app.json / app.config.ts 的 extra.eas.projectId
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Constants.expoConfig as any)?.extra?.eas?.projectId ||
undefined
);
}
export async function getExpoPushTokenOrThrow(): Promise<string> {
const projectId = getExpoProjectId();
try {
const res = projectId
? await Notifications.getExpoPushTokenAsync({ projectId })
: await Notifications.getExpoPushTokenAsync();
return res.data;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const hint = projectId
? ''
: '(可能缺少 EAS projectId建议在 app.json 的 extra.eas.projectId 配置后重试)';
throw new Error(`获取 Expo Push Token 失败:${msg}${hint}`);
}
}
export async function registerPushToken(args: { pushToken: string }): Promise<void> {
const clientUserId = await getOrCreateClientUserId();
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
const deviceMeta: PushDeviceMeta = {
os_version: String(Platform.Version ?? ''),
app_version: Constants.expoConfig?.version,
locale: pickLocale(),
timezone: pickTimezone(),
};
const body: PushRegisterRequest = {
client_user_id: clientUserId,
platform: pickPlatform(),
push_token: args.pushToken,
app_id: pickAppId(),
env: toPushEnv(APP_ENV),
device_meta: deviceMeta,
};
await httpJson<void>({
path: '/v1/push/register',
method: 'POST',
headers,
body,
timeoutMs: 10_000,
debugLabel: 'PushRegister',
});
}
export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise<PushPreferencesResponse> {
const clientUserId = await getOrCreateClientUserId();
const tz = pickTimezone();
const locale = pickLocale();
const scoringProfile = await getUserProfileScoring().catch(() => null);
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
const body: PushPreferencesRequest = {
client_user_id: clientUserId,
enabled: Boolean(args.enabled) && args.timesPerDay > 0,
times_per_day: Math.min(5, Math.max(0, Math.round(args.timesPerDay))),
timezone: tz,
locale,
user_profile: scoringProfile ?? undefined,
};
return await httpJson<PushPreferencesResponse>({
path: '/v1/push/preferences',
method: 'PUT',
headers,
body,
timeoutMs: 10_000,
debugLabel: 'PushPreferences',
});
}
export async function getPushPreferences(): Promise<PushPreferencesResponse> {
const clientUserId = await getOrCreateClientUserId();
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
const qs = `client_user_id=${encodeURIComponent(clientUserId)}`;
return await httpJson<PushPreferencesResponse>({
path: `/v1/push/preferences?${qs}`,
method: 'GET',
headers,
timeoutMs: 8_000,
debugLabel: 'PushPreferencesGet',
});
}
// 预留:未来推送文案个性化需要上报用户画像时使用(本期先不强制依赖)
export async function setPushProfileHint(_profile: UserProfileScoring | null): Promise<void> {
// 本期不实现:后端通过现有推荐模块自行拉取/计算 push 文案
}
export async function syncPushPreferencesFromLocal(): Promise<void> {
const s = await getDailyReminderSettings();
await setPushPreferences({ enabled: s.pushEnabled, timesPerDay: s.timesPerDay });
}

View File

@@ -1,7 +1,7 @@
import i18n from 'i18next';
import { API_BASE_URL } from '@/src/constants/env';
import type { UserProfileV1_2 } from '@/src/features/userProfileScoring';
import type { UserProfileV1_2 } from '../features/userProfileScoring';
import { httpJson } from '../utils/http';
export type RecommendedItem = {
content_id: number;
@@ -26,19 +26,10 @@ export type RecoRequest = {
now?: string; // ISO8601可选
};
function withTimeout(ms: number): AbortController {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller;
}
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
const controller = withTimeout(12_000);
const url = `${API_BASE_URL}/v1/reco/feed`;
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
// 让后端做 locale 选择(目前后端只区分 en/tc
'Accept-Language': acceptLanguage,
};
@@ -50,25 +41,39 @@ export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult>
now: req.now,
};
// 仅在开发环境打印,避免生产环境日志泄露敏感信息
if (__DEV__) {
console.log('[Feed API] 请求地址:', url);
console.log('[Feed API] Feed的API请求头:', headers);
console.log('[Feed API] Feed的API请求体:', bodyObj);
}
const res = await fetch(url, {
return await httpJson<RecoEngineResult>({
path: '/v1/reco/feed',
method: 'POST',
headers,
body: JSON.stringify(bodyObj),
signal: controller.signal,
body: bodyObj,
timeoutMs: 12_000,
debugLabel: 'Feed API',
});
}
export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> {
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
const headers: Record<string, string> = {
// 让后端做 locale 选择(目前后端只区分 en/tc
'Accept-Language': acceptLanguage,
};
const bodyObj = {
k: req.k ?? 1,
user_profile: req.user_profile,
already_recommended_ids: req.already_recommended_ids ?? [],
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
now: req.now,
};
return await httpJson<RecoEngineResult>({
path: '/v1/reco/widget',
method: 'POST',
headers,
body: bodyObj,
timeoutMs: 12_000,
debugLabel: 'Widget API',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`推荐接口请求失败:${res.status} ${res.statusText} ${text}`.trim());
}
return (await res.json()) as RecoEngineResult;
}

View File

@@ -1,9 +1,11 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Crypto from 'expo-crypto';
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
/**
* 本地存储 key 统一管理,避免 UI 里散落硬编码
*/
const KEY_CLIENT_USER_ID = 'client.userId';
const KEY_ONBOARDING_COMPLETED = 'onboarding.completed';
const KEY_PUSH_PROMPT_STATE = 'push.promptState';
const KEY_CONTENT_REACTIONS = 'content.reactions';
@@ -31,7 +33,16 @@ export type UserProfile = {
*/
export type UserProfileScoring = UserProfileV1_2_Extended;
export type DailyReminderSettings = {
/**
* 每天推送次数:
* - 0关闭
* - 15每天推送 15 次
*/
timesPerDay: number;
/**
* 仅用于 UI 展示与交互(开关)。
* 后端偏好建议使用enabled = pushEnabled && timesPerDay > 0
*/
pushEnabled: boolean;
};
@@ -81,6 +92,49 @@ async function setJson<T>(key: string, value: T): Promise<void> {
await AsyncStorage.setItem(key, JSON.stringify(value));
}
function looksLikeUuid(v: string): boolean {
// 宽松校验8-4-4-4-12不强依赖大小写
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
function uuidV4FromBytes(bytes: Uint8Array): string {
// RFC 4122 v4设置 version 与 variant
const b = new Uint8Array(bytes);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const hex = Array.from(b)
.map((x) => x.toString(16).padStart(2, '0'))
.join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
async function generateUuidV4(): Promise<string> {
// 1) 优先使用运行时提供的 randomUUID如可用
const maybeCrypto = (globalThis as unknown as { crypto?: { randomUUID?: () => string } }).crypto;
if (maybeCrypto?.randomUUID) return maybeCrypto.randomUUID();
// 2) 使用 expo-crypto 生成安全随机数(推荐)
const bytes = await Crypto.getRandomBytesAsync(16);
return uuidV4FromBytes(bytes);
}
/**
* 获取或生成客户端用户标识UUID
*
* 说明:
* - `client_user_id` 用于与后端关联 Push Token 与用户推送偏好
* - 它是“安装实例标识”,不等同真实用户账号
*/
export async function getOrCreateClientUserId(): Promise<string> {
const raw = await AsyncStorage.getItem(KEY_CLIENT_USER_ID);
if (raw && looksLikeUuid(raw)) return raw;
const next = await generateUuidV4();
await AsyncStorage.setItem(KEY_CLIENT_USER_ID, next);
return next;
}
export async function getOnboardingCompleted(): Promise<boolean> {
const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED);
return raw === 'true';
@@ -272,13 +326,15 @@ export async function getDailyReminderSettings(): Promise<DailyReminderSettings>
});
const timesPerDay = Number.isFinite(s.timesPerDay) ? s.timesPerDay : 3;
return {
timesPerDay: Math.min(10, Math.max(1, Math.round(timesPerDay))),
// 需求050 表示关闭)
timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))),
pushEnabled: Boolean(s.pushEnabled),
};
}
export async function setDailyReminderSettings(settings: DailyReminderSettings): Promise<void> {
const timesPerDay = Math.min(10, Math.max(1, Math.round(settings.timesPerDay)));
// 需求050 表示关闭)
const timesPerDay = Math.min(5, Math.max(0, Math.round(settings.timesPerDay)));
await setJson(KEY_DAILY_REMINDER_SETTINGS, {
timesPerDay,
pushEnabled: Boolean(settings.pushEnabled),

14
client/src/utils/date.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* 生成本地日维度 key用户时区YYYY-MM-DD
*
* 说明:
* - 使用 JS Date 的本地时间字段getFullYear/getMonth/getDate
* - 不依赖额外库,避免时区坑扩大化
*/
export function getLocalDayKey(date: Date = new Date()): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}

146
client/src/utils/http.ts Normal file
View File

@@ -0,0 +1,146 @@
import { API_BASE_URL } from '../constants/env';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export class HttpError extends Error {
readonly name = 'HttpError';
readonly url: string;
readonly status: number;
readonly statusText: string;
readonly responseText?: string;
constructor(args: { message: string; url: string; status: number; statusText: string; responseText?: string }) {
super(args.message);
this.url = args.url;
this.status = args.status;
this.statusText = args.statusText;
this.responseText = args.responseText;
}
}
export type HttpJsonOptions = {
/**
* 支持传入完整 URL 或以 / 开头的 path会自动拼到 API_BASE_URL
*/
path: string;
method?: HttpMethod;
headers?: Record<string, string>;
/**
* 将对象自动 JSON.stringifyGET 请求请不要传 body
*/
body?: unknown;
/**
* 超时(毫秒),默认 10 秒
*/
timeoutMs?: number;
/**
* 外部 signal例如上层取消请求
*/
signal?: AbortSignal;
/**
* 仅开发环境日志:便于联调排查
*/
debugLabel?: string;
};
function isAbsoluteUrl(path: string): boolean {
return /^https?:\/\//i.test(path);
}
function joinUrl(baseUrl: string, path: string): string {
const base = (baseUrl || '').replace(/\/+$/, '');
const p = (path || '').trim();
if (!p) return base;
if (p.startsWith('/')) return `${base}${p}`;
return `${base}/${p}`;
}
function createTimeoutAbortSignal(timeoutMs: number, external?: AbortSignal): AbortController {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs);
// 避免 Node/Vitest 下悬挂定时器影响退出
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(t as any)?.unref?.();
if (external) {
if (external.aborted) {
controller.abort();
} else {
external.addEventListener(
'abort',
() => {
controller.abort();
},
{ once: true },
);
}
}
return controller;
}
function isDev(): boolean {
return typeof __DEV__ !== 'undefined' && __DEV__;
}
export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
const method = opts.method ?? 'GET';
const timeoutMs = opts.timeoutMs ?? 10_000;
const url = isAbsoluteUrl(opts.path) ? opts.path : joinUrl(API_BASE_URL, opts.path);
const headers: Record<string, string> = {
...(opts.headers ?? {}),
};
const hasBody = typeof opts.body !== 'undefined' && opts.body !== null;
const body = hasBody ? JSON.stringify(opts.body) : undefined;
// 仅在有 body 时默认补齐 Content-Type避免 GET 请求无意义携带
if (hasBody && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
if (isDev() && opts.debugLabel) {
console.log(`[${opts.debugLabel}] 请求地址:`, url);
console.log(`[${opts.debugLabel}] 请求方法:`, method);
console.log(`[${opts.debugLabel}] 请求头:`, headers);
if (hasBody) console.log(`[${opts.debugLabel}] 请求体:`, opts.body);
}
const controller = createTimeoutAbortSignal(timeoutMs, opts.signal);
let res: Response;
try {
res = await fetch(url, {
method,
headers,
body,
signal: controller.signal,
});
} catch (e) {
// RN 下 AbortError 文案不完全一致,这里统一对外语义
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`网络请求失败:${msg}`);
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new HttpError({
message: `HTTP 请求失败:${res.status} ${res.statusText} ${text}`.trim(),
url,
status: res.status,
statusText: res.statusText,
responseText: text,
});
}
// 204/205 无内容时不要强行 parse
if (res.status === 204 || res.status === 205) {
return undefined as unknown as T;
}
return (await res.json()) as T;
}

View File

@@ -0,0 +1,123 @@
"""init push tables
Revision ID: 0002_init_push_tables
Revises: 0001_init_content_tables
Create Date: 2026-02-03
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = "0002_init_push_tables"
down_revision = "0001_init_content_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
# push_tokens
op.create_table(
"push_tokens",
sa.Column(
"id",
mysql.BIGINT(unsigned=True),
primary_key=True,
autoincrement=True,
comment="主键",
),
sa.Column("client_user_id", sa.String(length=64), nullable=False, comment="客户端用户标识UUID"),
sa.Column(
"platform",
sa.Enum("ios", "android", name="push_platform"),
nullable=False,
comment="平台",
),
sa.Column("push_token", sa.String(length=255), nullable=False, comment="Expo Push Token"),
sa.Column("app_id", sa.String(length=255), nullable=False, comment="bundle id / package name用于隔离"),
sa.Column(
"env",
sa.Enum("dev", "prod", name="push_env"),
nullable=False,
comment="环境隔离",
),
sa.Column(
"is_active",
sa.Boolean(),
server_default=sa.text("1"),
nullable=False,
comment="是否有效(发送失败且不可恢复时置为 false",
),
sa.Column("last_seen_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="最后一次上报时间"),
sa.UniqueConstraint("env", "app_id", "push_token", name="uniq_env_app_token"),
mysql_charset="utf8mb4",
)
op.create_index("idx_push_tokens_client_user_id", "push_tokens", ["client_user_id"], unique=False)
op.create_index("idx_push_tokens_is_active", "push_tokens", ["is_active"], unique=False)
# push_preferences
op.create_table(
"push_preferences",
sa.Column("client_user_id", sa.String(length=64), primary_key=True, comment="客户端用户标识UUID"),
sa.Column(
"enabled",
sa.Boolean(),
server_default=sa.text("0"),
nullable=False,
comment="是否开启每日提醒enabled=false 或 times_per_day=0 均视为关闭)",
),
sa.Column(
"times_per_day",
sa.SmallInteger(),
server_default="0",
nullable=False,
comment="每天推送次数05",
),
sa.Column("timezone", sa.String(length=64), nullable=True, comment="IANA 时区(例如 Asia/Shanghai来自客户端上报"),
sa.Column("locale", sa.String(length=32), nullable=True, comment="客户端语言(例如 zh-CN/en/zh-TW用于文案语言选择"),
sa.Column("user_profile_json", sa.JSON(), nullable=True, comment="用户画像V1.2;可选)。用于 Push 场景推荐文案生成。"),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="更新时间"),
mysql_charset="utf8mb4",
)
# push_send_log
op.create_table(
"push_send_log",
sa.Column(
"id",
mysql.BIGINT(unsigned=True),
primary_key=True,
autoincrement=True,
comment="主键",
),
sa.Column("client_user_id", sa.String(length=64), nullable=False, comment="客户端用户标识UUID"),
sa.Column("local_date", sa.Date(), nullable=False, comment="用户时区的本地日期(用于幂等)"),
sa.Column("slot_index", sa.SmallInteger(), nullable=False, comment="当天第几条1..times_per_day"),
sa.Column("scheduled_at", sa.DateTime(), nullable=False, comment="计划发送时间UTC"),
sa.Column("sent_at", sa.DateTime(), nullable=True, comment="实际发送时间UTC"),
sa.Column("status", sa.String(length=16), server_default="scheduled", nullable=False, comment="scheduled/sent/failed"),
sa.Column("error", sa.Text(), nullable=True, comment="失败原因(可选)"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"),
sa.UniqueConstraint("client_user_id", "local_date", "slot_index", name="uniq_user_date_slot"),
mysql_charset="utf8mb4",
)
op.create_index("idx_push_log_user_date", "push_send_log", ["client_user_id", "local_date"], unique=False)
op.create_index("idx_push_log_status", "push_send_log", ["status"], unique=False)
def downgrade() -> None:
op.drop_index("idx_push_log_status", table_name="push_send_log")
op.drop_index("idx_push_log_user_date", table_name="push_send_log")
op.drop_table("push_send_log")
op.drop_table("push_preferences")
op.drop_index("idx_push_tokens_is_active", table_name="push_tokens")
op.drop_index("idx_push_tokens_client_user_id", table_name="push_tokens")
op.drop_table("push_tokens")

View File

@@ -47,6 +47,7 @@ class FixedWindowRateLimiter:
_reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60)
_push_rate_limiter = FixedWindowRateLimiter(limit=30, window_seconds=60)
async def rate_limit_reco_by_ip(request: Request) -> None:
@@ -60,3 +61,19 @@ async def rate_limit_reco_by_ip(request: Request) -> None:
_reco_rate_limiter.allow(key=ip, now_ts=time.time())
async def rate_limit_push_by_ip(request: Request) -> None:
"""
推送相关接口限流:按 IP1 分钟 30 次。
说明:
- register/preferences 等接口可能在客户端反复重试
- 本期先用内存固定窗口限流做基础保护
"""
ip = "unknown"
if request.client and request.client.host:
ip = str(request.client.host)
_push_rate_limiter.allow(key=ip, now_ts=time.time())

131
server/app/api/v1/legal.py Normal file
View File

@@ -0,0 +1,131 @@
from __future__ import annotations
from typing import Literal, Optional
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, HttpUrl
from app.core.config import get_settings
from app.legal_docs import PRIVACY_POLICY_MD, TERMS_OF_USE_MD, choose_content_by_lang, render_as_simple_html
router = APIRouter(prefix="/v1/legal", tags=["legal"])
INTERNAL_SENTINEL = "__internal__"
# 当前多语言仅支持 EN / TC繁体
ResolvedLang = Literal["en", "tc"]
class LegalLinksResponse(BaseModel):
privacyPolicyUrl: HttpUrl
termsOfUseUrl: HttpUrl
resolvedLang: ResolvedLang
def _resolve_lang(accept_language: Optional[str]) -> ResolvedLang:
"""
从 Accept-Language 里做一个轻量语言解析。
说明:
- 只关心en / tc繁体
- 解析失败或缺失:回退 en
"""
if not accept_language:
return "en"
s = accept_language.lower()
# 目前只支持 EN / TC只要是中文或显式 tc都归到 tc
if "tc" in s:
return "tc"
if "zh" in s or "hant" in s or "tw" in s or "hk" in s or "mo" in s:
return "tc"
return "en"
def _pick_urls(lang: ResolvedLang) -> tuple[str, str, ResolvedLang]:
settings = get_settings()
if lang == "tc":
privacy = settings.legal_privacy_url_tc or settings.legal_privacy_url_en
terms = settings.legal_terms_url_tc or settings.legal_terms_url_en
# 如果未配置 tc 链接:
# - 若走内置页面__internal__可直接展示 tc 内容,因此 resolved=tc
# - 否则回退到 en 链接,因此 resolved=en
if settings.legal_privacy_url_tc or settings.legal_terms_url_tc:
resolved: ResolvedLang = "tc"
elif privacy == INTERNAL_SENTINEL or terms == INTERNAL_SENTINEL:
resolved = "tc"
else:
resolved = "en"
return privacy, terms, resolved
return settings.legal_privacy_url_en, settings.legal_terms_url_en, "en"
def _join_base_url(base_url: str, path: str) -> str:
base = (base_url or "").rstrip("/")
p = (path or "").strip()
if not p.startswith("/"):
p = "/" + p
return base + p
def _normalize_internal_url(request: Request, url: str, internal_path: str) -> str:
"""
将内置哨兵值替换为当前服务的可访问绝对 URL。
"""
if url != INTERNAL_SENTINEL:
return url
return _join_base_url(str(request.base_url), internal_path)
@router.get("/links", response_model=LegalLinksResponse)
async def get_legal_links(request: Request) -> LegalLinksResponse:
"""
获取协议链接(隐私协议 / 使用协议)。
- 语言来源Accept-Language
- 默认兜底en
"""
accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language)
privacy, terms, resolved = _pick_urls(lang)
privacy = _normalize_internal_url(request, privacy, "/v1/legal/privacy")
terms = _normalize_internal_url(request, terms, "/v1/legal/terms")
return LegalLinksResponse(privacyPolicyUrl=privacy, termsOfUseUrl=terms, resolvedLang=resolved)
@router.get("/privacy", response_class=HTMLResponse)
async def get_privacy_policy(request: Request) -> HTMLResponse:
"""
内置隐私协议页面(用于未配置外部托管链接时的兜底)。
"""
accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
title = "Hey Mama | Privacy Policy" if resolved == "en" else "Hey Mama隱私權政策"
page = render_as_simple_html(title=title, content=content)
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
@router.get("/terms", response_class=HTMLResponse)
async def get_terms_of_use(request: Request) -> HTMLResponse:
"""
内置使用协议页面(用于未配置外部托管链接时的兜底)。
"""
accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
title = "Hey Mama Terms of Use" if resolved == "en" else "Hey Mama 使用條款"
page = render_as_simple_html(title=title, content=content)
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})

262
server/app/api/v1/push.py Normal file
View File

@@ -0,0 +1,262 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Literal, Optional
import httpx
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.limits import rate_limit_push_by_ip
from app.core.config import get_settings
from app.db.models.push_preference import PushPreference
from app.db.models.push_token import PushToken
from app.db.session import get_db
from app.features.user_profile_scoring.types import UserProfileV1_2
router = APIRouter(
prefix="/v1/push",
tags=["push"],
dependencies=[Depends(rate_limit_push_by_ip)],
)
PushEnv = Literal["dev", "prod"]
PushPlatform = Literal["ios", "android"]
class PushDeviceMeta(BaseModel):
model: Optional[str] = None
os_version: Optional[str] = None
app_version: Optional[str] = None
locale: Optional[str] = None
timezone: Optional[str] = None
class PushRegisterRequest(BaseModel):
client_user_id: str = Field(min_length=8, max_length=64)
platform: PushPlatform
push_token: str = Field(min_length=8, max_length=255)
app_id: str = Field(min_length=1, max_length=255)
env: PushEnv
device_meta: Optional[PushDeviceMeta] = None
class PushPreferencesRequest(BaseModel):
client_user_id: str = Field(min_length=8, max_length=64)
enabled: bool
times_per_day: int = Field(ge=0, le=5)
timezone: Optional[str] = None
locale: Optional[str] = None
# 可选:用户画像(用于 Push 场景推荐文案生成)
user_profile: Optional[UserProfileV1_2] = None
class PushPreferencesResponse(BaseModel):
client_user_id: str
enabled: bool
times_per_day: int
timezone: Optional[str] = None
locale: Optional[str] = None
updated_at: Optional[str] = None
class PushTestRequest(BaseModel):
client_user_id: str = Field(min_length=8, max_length=64)
title: Optional[str] = None
body: Optional[str] = None
def _ensure_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
async def _pick_active_token(db: AsyncSession, *, client_user_id: str) -> Optional[PushToken]:
# 取最近一次上报的 active token
q = (
select(PushToken)
.where(PushToken.client_user_id == client_user_id, PushToken.is_active == True) # noqa: E712
.order_by(PushToken.last_seen_at.desc())
.limit(1)
)
row = await db.execute(q)
return row.scalar_one_or_none()
async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]:
"""
发送 Expo Push。
说明:
- V1最小可用实现满足 test 与后续定时任务调用
- 失败处理与 token 停用在后续任务逻辑中完善
"""
settings = get_settings()
url = "https://exp.host/--/api/v2/push/send"
headers: dict[str, str] = {
"Content-Type": "application/json",
}
if settings.expo_access_token:
headers["Authorization"] = f"Bearer {settings.expo_access_token}"
payload: dict[str, Any] = {"to": to, "title": title, "body": body}
if data:
payload["data"] = data
async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.post(url, headers=headers, json=payload)
if res.status_code >= 400:
raise HTTPException(status_code=502, detail=f"expo_push_failed:{res.status_code}")
return res.json()
@router.post("/register")
async def register(req: PushRegisterRequest, db: AsyncSession = Depends(get_db)) -> dict[str, str]:
"""
注册/更新 Push Token幂等
"""
now = datetime.now(timezone.utc)
# 以 env+app_id+push_token 唯一:存在则更新归属与 last_seen
q = select(PushToken).where(
PushToken.env == req.env,
PushToken.app_id == req.app_id,
PushToken.push_token == req.push_token,
)
row = await db.execute(q)
token = row.scalar_one_or_none()
if token is None:
token = PushToken(
client_user_id=req.client_user_id,
platform=req.platform,
push_token=req.push_token,
app_id=req.app_id,
env=req.env,
is_active=True,
last_seen_at=_ensure_utc(now),
)
db.add(token)
else:
token.client_user_id = req.client_user_id
token.platform = req.platform
token.is_active = True
token.last_seen_at = _ensure_utc(now)
await db.commit()
return {"status": "ok"}
@router.put("/preferences", response_model=PushPreferencesResponse)
async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depends(get_db)) -> PushPreferencesResponse:
"""
设置每日提醒偏好(幂等)。
"""
# 规范化enabled=false 或 times=0 均视为关闭
times = int(req.times_per_day)
enabled = bool(req.enabled) and times > 0
q = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id)
row = await db.execute(q)
pref = row.scalar_one_or_none()
if pref is None:
pref = PushPreference(
client_user_id=req.client_user_id,
enabled=enabled,
times_per_day=times,
timezone=req.timezone,
locale=req.locale,
# 注意Pydantic 会把 ISO8601 字符串解析成 datetime
# SQLAlchemy JSON 列默认使用 json.dumps无法序列化 datetime。
# 这里用 mode="json" 保证写库内容都是可 JSON 序列化的基础类型datetime → ISO 字符串)。
user_profile_json=req.user_profile.model_dump(mode="json") if req.user_profile else None,
)
db.add(pref)
else:
pref.enabled = enabled
pref.times_per_day = times
pref.timezone = req.timezone
pref.locale = req.locale
if req.user_profile is not None:
pref.user_profile_json = req.user_profile.model_dump(mode="json")
await db.commit()
# 返回更新时间(从 ORM 读取到的可能不包含 server_onupdate这里用 now 兜底)
updated_at = getattr(pref, "updated_at", None)
updated_at_iso = updated_at.isoformat() if isinstance(updated_at, datetime) else None
return PushPreferencesResponse(
client_user_id=req.client_user_id,
enabled=enabled,
times_per_day=times,
timezone=req.timezone,
locale=req.locale,
updated_at=updated_at_iso,
)
@router.get("/preferences", response_model=PushPreferencesResponse)
async def get_preferences(
client_user_id: str = Query(min_length=8, max_length=64),
db: AsyncSession = Depends(get_db),
) -> PushPreferencesResponse:
q = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
row = await db.execute(q)
pref = row.scalar_one_or_none()
if pref is None:
return PushPreferencesResponse(client_user_id=client_user_id, enabled=False, times_per_day=0)
updated_at_iso = pref.updated_at.isoformat() if pref.updated_at else None
return PushPreferencesResponse(
client_user_id=client_user_id,
enabled=bool(pref.enabled) and int(pref.times_per_day) > 0,
times_per_day=int(pref.times_per_day),
timezone=pref.timezone,
locale=pref.locale,
updated_at=updated_at_iso,
)
@router.post("/test")
async def test_push(
req: PushTestRequest,
db: AsyncSession = Depends(get_db),
accept_language: Optional[str] = Header(default=None, alias="Accept-Language"),
) -> dict[str, Any]:
"""
立即测试推送(仅用于 dev 联调)。
"""
settings = get_settings()
if settings.app_env != "dev":
raise HTTPException(status_code=403, detail="test_only_in_dev")
token = await _pick_active_token(db, client_user_id=req.client_user_id)
if token is None:
raise HTTPException(status_code=404, detail="no_active_token")
# 如果没有用户画像,也不影响 test 推送;文案按请求/默认文案发送。
q = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id)
row = await db.execute(q)
pref = row.scalar_one_or_none()
if pref and pref.user_profile_json:
_ = UserProfileV1_2.model_validate(pref.user_profile_json)
# V1先发固定测试文案后续在定时任务中替换为推荐模块的 push 场景模板
title = req.title or "Hey Mama"
body = req.body or "这是一条测试推送dev"
expo_res = await _send_expo_push(to=token.push_token, title=title, body=body, data={"client_user_id": req.client_user_id})
_ = accept_language
return {"status": "ok", "expo": expo_res}

View File

@@ -46,6 +46,24 @@ class Settings(BaseSettings):
celery_broker_url: str
celery_result_backend: Optional[str] = None
# 法务协议链接(由后端统一托管并下发给客户端)
# 说明:
# - 当前仅支持 EN / TC 两种语言(与客户端现状一致)
# - 若未显式配置 LEGAL_*,后端会使用“内置协议页面”(/v1/legal/privacy、/v1/legal/terms
# - 线上建议配置为你们的官网/托管页/静态站点 HTTPS 链接,避免与 API 域名强绑定
#
# 这里用一个内部哨兵值表示“走内置页面”,避免默认值误导为可用的公网链接。
legal_privacy_url_en: str = "__internal__"
legal_terms_url_en: str = "__internal__"
legal_privacy_url_tc: Optional[str] = None
legal_terms_url_tc: Optional[str] = None
# Expo Push可选
# 说明:
# - 不填也能调用 Expo Push API但会受更严格的速率限制
# - 建议生产环境配置 EXPO_ACCESS_TOKEN便于稳定性与配额
expo_access_token: Optional[str] = None
model_config = SettingsConfigDict(
env_prefix="",
case_sensitive=False,

View File

@@ -9,6 +9,9 @@
from app.db.models.content import Content
from app.db.models.content_profile import ContentProfile
from app.db.models.content_risk_flag import ContentRiskFlag
from app.db.models.push_preference import PushPreference
from app.db.models.push_send_log import PushSendLog
from app.db.models.push_token import PushToken
__all__ = ["Content", "ContentProfile", "ContentRiskFlag"]
__all__ = ["Content", "ContentProfile", "ContentRiskFlag", "PushPreference", "PushSendLog", "PushToken"]

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, JSON, SmallInteger, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PushPreference(Base):
"""
用户推送偏好(以 client_user_id 为主键)。
"""
__tablename__ = "push_preferences"
client_user_id: Mapped[str] = mapped_column(
String(length=64),
primary_key=True,
comment="客户端用户标识UUID",
)
enabled: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
server_default="0",
comment="是否开启每日提醒enabled=false 或 times_per_day=0 均视为关闭)",
)
times_per_day: Mapped[int] = mapped_column(
SmallInteger,
nullable=False,
server_default="0",
comment="每天推送次数05",
)
timezone: Mapped[str | None] = mapped_column(
String(length=64),
nullable=True,
comment="IANA 时区(例如 Asia/Shanghai来自客户端上报",
)
locale: Mapped[str | None] = mapped_column(
String(length=32),
nullable=True,
comment="客户端语言(例如 zh-CN/en/zh-TW用于文案语言选择",
)
user_profile_json: Mapped[dict | None] = mapped_column(
JSON,
nullable=True,
comment="用户画像V1.2;可选)。用于 Push 场景推荐文案生成。",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
server_onupdate=func.now(),
comment="更新时间",
)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Index, SmallInteger, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PushSendLog(Base):
"""
推送发送日志(用于幂等防重复 + 可观测)。
"""
__tablename__ = "push_send_log"
__table_args__ = (
UniqueConstraint("client_user_id", "local_date", "slot_index", name="uniq_user_date_slot"),
Index("idx_push_log_user_date", "client_user_id", "local_date"),
Index("idx_push_log_status", "status"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, comment="主键")
client_user_id: Mapped[str] = mapped_column(String(length=64), nullable=False, comment="客户端用户标识UUID")
local_date: Mapped[date] = mapped_column(Date, nullable=False, comment="用户时区的本地日期(用于幂等)")
slot_index: Mapped[int] = mapped_column(SmallInteger, nullable=False, comment="当天第几条1..times_per_day")
scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, comment="计划发送时间UTC")
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="实际发送时间UTC")
status: Mapped[str] = mapped_column(String(length=16), nullable=False, server_default="scheduled", comment="scheduled/sent/failed")
error: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因(可选)")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), comment="创建时间")

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from sqlalchemy import Boolean, DateTime, Enum, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
PushEnv = Literal["dev", "prod"]
PushPlatform = Literal["ios", "android"]
class PushToken(Base):
"""
Push Token 绑定表Expo Push Token
约束:
- token 在同一 env + app_id 下必须唯一(避免重复推送)
- 同一 client_user_id 可能会更新 token重装/轮换/重新授权)
"""
__tablename__ = "push_tokens"
__table_args__ = (
UniqueConstraint("env", "app_id", "push_token", name="uniq_env_app_token"),
Index("idx_push_tokens_client_user_id", "client_user_id"),
Index("idx_push_tokens_is_active", "is_active"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, comment="主键")
client_user_id: Mapped[str] = mapped_column(String(length=64), nullable=False, comment="客户端用户标识UUID")
platform: Mapped[PushPlatform] = mapped_column(
Enum("ios", "android", name="push_platform"),
nullable=False,
comment="平台",
)
push_token: Mapped[str] = mapped_column(String(length=255), nullable=False, comment="Expo Push Token")
app_id: Mapped[str] = mapped_column(String(length=255), nullable=False, comment="bundle id / package name用于隔离")
env: Mapped[PushEnv] = mapped_column(
Enum("dev", "prod", name="push_env"),
nullable=False,
comment="环境隔离",
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
server_default="1",
comment="是否有效(发送失败且不可恢复时置为 false",
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
server_onupdate=func.now(),
comment="最后一次上报时间",
)

305
server/app/legal_docs.py Normal file
View File

@@ -0,0 +1,305 @@
from __future__ import annotations
import html
import re
from typing import Literal
ResolvedLang = Literal["en", "tc"]
# 协议原文(直接来自仓库中的 Markdown 文档)。
# 说明:为了保证“点击一定有内容”,这里在后端内置了一份可展示的协议文本。
# 线上若你们有官网/静态站点托管页面,可通过环境变量 LEGAL_* 覆盖为外部链接。
PRIVACY_POLICY_MD = """Hey Mama | Privacy Policy
Last updated: February 2026
1. Introduction
Welcome to Hey Mama (“the App,” “we,” “us”).
We respect your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect information when you use the App.
By downloading, accessing, or using the App, you acknowledge that you have read, understood, and agreed to this Privacy Policy.
2. Data Controller and Scope
The App is operated and maintained by the Hey Mama team.
This Privacy Policy applies to information processing activities related to your use of the App.
3. Information We Collect
3.1 Information You Provide
Hey Mama does not require account registration and does not require you to provide personally identifiable information.
During your use of the App, you may optionally provide or generate the following information:
- Reminder settings (e.g., reminder frequency)
- Text content you view, save as favorites, or create within the App (if available)
This information is used only to operate core App features and provide a personalized experience.
3.2 Information Collected Automatically
When you use the App, we may automatically collect certain non-identifiable technical information, including but not limited to:
- Device type and operating system version
- App version
- Device language settings
- Basic usage status (e.g., whether the App is opened, whether reminders are enabled)
This information does not directly identify you and is primarily used to maintain App stability, troubleshoot issues, and improve user experience.
The App does not collect precise location information for the purposes described in this policy, does not engage in cross-app tracking, and does not use your data for third-party advertising.
4. Push Notifications
With your permission, the App may send you reminder notifications, such as daily affirmations.
- Notifications contain general text information only
- Notifications do not include sensitive personal data
- You can disable notifications at any time in your device settings
5. Home Screen Widgets
If you choose to use home screen widgets, the displayed content comes from the Apps text-based affirmations. Widgets do not collect or transmit additional personal data.
6. How We Use Information
We use collected information only for the following purposes:
- To provide and maintain core App functionality
- To improve content presentation and user experience
- To fix bugs and enhance system stability
We do not:
- Sell, rent, or trade your personal data
- Use your data for third-party advertising purposes
7. Third-Party Services
Hey Mama currently does not integrate third-party advertising or marketing services.
The App may rely on necessary operating system and app store services to provide functionality (for example, push notification delivery mechanisms).
If we later integrate third-party analytics or technical services, we will update this Privacy Policy accordingly.
8. Data Retention and Security
We retain information only for as long as necessary to achieve the purposes described above. We implement reasonable technical and organizational measures to protect information against unauthorized access, disclosure, alteration, or loss.
9. Minors
Hey Mama is not designed for children, and we do not knowingly collect personal information from users under the age of 13.
If you are a minor, please use the App with the consent and supervision of a parent or guardian.
10. Changes to This Privacy Policy
We may update this Privacy Policy from time to time. The updated version will be made available within the App or through related pages. If you continue to use the App after updates take effect, you are deemed to have accepted the updated policy.
---
Hey Mama隱私權政策
最後更新日期2026 年 2 月
一、前言
歡迎使用 Hey Mama以下簡稱「本 App」、「我們」
我們重視您的隱私,並致力於保護您的個人資料安全。本隱私權政策說明您在使用 Hey Mama 時,我們如何收集、使用、保存與保護相關資訊。
當您下載、存取或使用本 App即表示您已閱讀、理解並同意本隱私權政策之內容。
二、我們收集的資訊
1. 使用者主動提供的資訊
Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身分的資料。
在使用過程中,您可能會選擇性提供或產生以下資訊:
- 提醒設定(例如提醒頻率)
- 使用者在 App 內閱讀、收藏或建立的文字內容(如有)
上述資訊僅用於 App 功能運作與個人化體驗。
2. 自動收集的資訊
當您使用本 App 時,我們可能會自動收集部分非識別性技術資訊,包括但不限於:
- 裝置類型與作業系統版本
- App 版本
- 裝置語言設定
- 基本使用行為(例如是否開啟 App、是否啟用提醒
這些資訊無法直接識別您的身分,僅用於維持 App 穩定性與改善使用體驗。
三、推送通知
在取得您同意後Hey Mama 可能會向您發送提醒推送,例如每日肯定語提示。
- 推送內容僅包含一般文字資訊
- 不包含任何敏感個人資料
- 您可隨時於裝置系統設定中關閉通知功能
四、桌面小組件
若您選擇使用桌面小組件,其顯示內容僅來自 App 內的文字肯定語,不會額外收集或傳送新的個人資料。
五、資訊使用方式
我們僅於下列目的範圍內使用所收集的資訊:
- 提供與維護 App 的基本功能
- 改善內容呈現與使用體驗
- 修復錯誤與提升系統穩定性
我們不會:
- 出售、出租或交換您的個人資料
- 將資料用於第三方廣告投放
六、第三方服務
目前 Hey Mama 未整合第三方廣告或行銷服務。
如未來整合第三方分析或技術服務,我們將於本政策中另行說明並更新。
七、資料保存與安全
我們僅在達成上述目的所需期間內保存相關資訊,並採取合理的技術與管理措施,以防止資料遭未經授權存取、洩漏、竄改或遺失。
八、未成年人說明
Hey Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的個人資料。
若您為未成年人,請在監護人同意與陪同下使用本 App。
九、隱私權政策的變更
我們可能會不定期更新本隱私權政策。
更新後的版本將公布於 App 內或相關頁面,您於政策更新後繼續使用本 App即視為同意更新內容。
"""
TERMS_OF_USE_MD = """Hey Mama Terms of Use
Last updated: February 2026
Welcome to Hey Mama (“the App,” “we,” or “us”).
Please read these Terms of Use carefully before downloading, accessing, or using the App. By using the App, you agree to be bound by these Terms.
1. Intended Audience
Hey Mama is intended for adults only.
The App is not designed for children, and users must ensure they have the legal capacity to use the App under applicable laws.
2. Services Provided
Hey Mama provides text-based content and features, including but not limited to:
- Daily affirmations and mindfulness text
- User-configured reminders and push notifications
- Home screen widgets displaying affirmation text
- Personalized reading or saving experiences (where applicable)
All content is provided for general emotional support and self-reflection purposes only and does not constitute medical, psychological, or professional advice.
3. Acceptable Use
You agree to:
- Use the App for personal, non-commercial purposes only
- Not copy, reproduce, distribute, sell, modify, reverse engineer, or attempt to extract the source code of the App
- Not engage in any activity that may interfere with the Apps functionality, stability, or user experience
We reserve the right to restrict or terminate access if these Terms are violated.
4. Push Notifications
The App may send push notifications based on your settings (e.g. daily affirmation reminders).
- Notifications contain general text only
- No sensitive personal data is included
- You may disable notifications at any time through your device settings
5. Intellectual Property
All content, design elements, interfaces, and materials within the App are owned by us or our licensors and are protected by applicable intellectual property laws.
Unauthorized use, reproduction, or distribution is strictly prohibited.
6. Disclaimer
The App and its content are provided for informational and self-support purposes only.
- We do not guarantee specific emotional or psychological outcomes
- The App does not replace professional medical, mental health, or legal advice
- You are solely responsible for how you use the content
7. Service Availability
We may modify, suspend, or discontinue any part of the App at any time due to maintenance, updates, or circumstances beyond our control.
We are not liable for any loss or damage resulting from such interruptions or changes.
8. Changes to These Terms
We may update these Terms of Use from time to time.
Updated versions will be made available within the App or related pages. Continued use of the App after changes indicates acceptance of the revised Terms.
9. Governing Law
These Terms shall be governed by and construed in accordance with the applicable laws of our operating jurisdiction.
---
Hey Mama 使用條款
最後更新日期2026 年 2 月
歡迎使用 Hey Mama以下簡稱「本 App」、「我們」
在下載、存取或使用本 App 前,請您仔細閱讀本使用條款。當您開始使用本 App即表示您已閱讀、理解並同意遵守本條款。
1. 服務對象與使用資格
Hey Mama 僅供成年人使用intended for adults
本 App 並非為兒童設計,使用者應確認自己具備依所在地法律使用本服務的完全行為能力。
2. 服務內容
Hey Mama 提供以文字形式為主的內容與功能,包括但不限於:
- 每日肯定語與正念文字內容
- 使用者設定的提醒與推送通知
- 桌面小組件顯示肯定語文字
- 內容閱讀與收藏等個人化體驗(如適用)
本 App 所提供之內容僅作為日常情緒支持與自我提醒用途,不構成任何形式的醫療、心理諮商或專業建議。
3. 使用方式與限制
使用者同意:
- 僅將本 App 用於個人、非商業用途
- 不以任何方式複製、重製、散布、出售、反編譯或試圖取得本 App 之原始碼
- 不進行任何可能影響 App 正常運作、穩定性或其他使用者體驗之行為
如有違反,我們有權在不另行通知的情況下限制或終止使用權限。
4. 推送通知
本 App 可能依使用者設定發送推送通知(例如每日肯定語提醒)。
- 推送內容僅為一般文字資訊
- 不包含個人化敏感資料
- 您可隨時透過裝置系統設定關閉通知功能
5. 智慧財產權
本 App 及其所有內容(包含但不限於文字、設計、介面、版面配置與視覺元素)之智慧財產權,均屬於我們或合法授權方所有。
未經事前書面同意,任何形式之使用、修改、重製或散布皆屬禁止。
6. 免責聲明
本 App 所提供之內容僅供一般參考與自我提醒之用。
- 我們不保證內容能產生特定心理、情緒或行為結果
- 本 App 不取代任何專業醫療、心理或法律建議
- 使用者應自行判斷內容是否適合自身狀況
7. 服務中斷與變更
我們可能因系統維護、功能調整、更新或其他不可抗力因素,暫時中斷或變更本 App 之全部或部分功能。
對於因此可能造成的任何直接或間接損失,我們不負任何責任。
8. 條款修改
我們可能不定期更新本使用條款。
更新後的版本將公布於 App 內或相關頁面,您於條款更新後繼續使用本 App即視為同意更新內容。
9. 準據法與管轄
本使用條款之解釋與適用,悉依我們所在地之相關法律規定處理。
"""
def split_bilingual_markdown(text: str) -> tuple[str, str | None]:
"""
按文档中的 `---` 分隔符将内容拆成两段。
返回:
- 第一段:默认视为英文
- 第二段:默认视为繁体(可为空)
"""
parts = re.split(r"^\s*---\s*$", text, maxsplit=1, flags=re.MULTILINE)
if not parts:
return "", None
if len(parts) == 1:
return parts[0].strip(), None
return parts[0].strip(), parts[1].strip()
def choose_content_by_lang(text: str, lang: ResolvedLang) -> tuple[str, ResolvedLang]:
"""
根据 lang 选择展示内容。若 tc 内容缺失则回退 en。
"""
en, tc = split_bilingual_markdown(text)
if lang == "tc" and tc:
return tc, "tc"
return en, "en"
def render_as_simple_html(title: str, content: str) -> str:
"""
将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
"""
safe_title = html.escape(title)
safe_content = html.escape(content)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{safe_title}</title>
<style>
:root {{
color-scheme: light dark;
}}
body {{
margin: 0;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "PingFang TC", "PingFang SC", "Noto Sans CJK TC", "Noto Sans CJK SC", sans-serif;
line-height: 1.6;
}}
pre {{
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-size: 15px;
}}
</style>
</head>
<body>
<pre>{safe_content}</pre>
</body>
</html>
"""

View File

@@ -3,6 +3,8 @@ from fastapi import FastAPI
from app.core.config import get_settings
from app.api.v1.reco import router as reco_router
from app.api.v1.user_profile_scoring import router as user_profile_router
from app.api.v1.legal import router as legal_router
from app.api.v1.push import router as push_router
def create_app() -> FastAPI:
@@ -19,6 +21,8 @@ def create_app() -> FastAPI:
# 业务路由
app.include_router(user_profile_router)
app.include_router(reco_router)
app.include_router(legal_router)
app.include_router(push_router)
@app.get("/health")
async def health() -> dict:

View File

@@ -2,3 +2,12 @@
Celery 任务集合。
"""
# 重要:
# - 本项目的任务按文件拆分在 app/tasks/*.py 中
# - Celery autodiscover 通常只会导入 app.tasks即本包不会自动递归导入子模块
# - 因此这里需要显式导入各任务模块,确保 shared_task 被注册
from app.tasks import ping as _ping # noqa: F401
from app.tasks import reco as _reco # noqa: F401
from app.tasks import push as _push # noqa: F401

312
server/app/tasks/push.py Normal file
View File

@@ -0,0 +1,312 @@
from __future__ import annotations
import asyncio
import random
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from typing import Any, Optional
from zoneinfo import ZoneInfo
import httpx
from celery import current_app, shared_task
from sqlalchemy import select
from app.core.config import get_settings
from app.db.models.push_preference import PushPreference
from app.db.models.push_send_log import PushSendLog
from app.db.models.push_token import PushToken
from app.db.session import AsyncSessionLocal
from app.features.personalized_reco.content_repository.types import normalize_locale
from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire
from app.features.user_profile_scoring.types import QuestionnaireAnswersV1_2, UserProfileV1_2
def _ensure_tz(tz_name: Optional[str]) -> ZoneInfo:
raw = (tz_name or "").strip()
if not raw:
return ZoneInfo("UTC")
try:
return ZoneInfo(raw)
except Exception:
return ZoneInfo("UTC")
def _pick_reco_locale(pref_locale: Optional[str]) -> str:
"""
将客户端 locale如 zh-CN/en/zh-TW映射到推荐系统 localeen/tc
"""
raw = (pref_locale or "").strip().lower()
if raw.startswith("zh"):
return "tc"
# 其他语言在当前版本统一回退到 en与现有 reco/ legal 链路一致)
return "en"
def _pick_title(locale: str) -> str:
return "每日提醒" if str(locale) == "tc" else "Daily Reminder"
async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]:
settings = get_settings()
url = "https://exp.host/--/api/v2/push/send"
headers: dict[str, str] = {"Content-Type": "application/json"}
if settings.expo_access_token:
headers["Authorization"] = f"Bearer {settings.expo_access_token}"
payload: dict[str, Any] = {"to": to, "title": title, "body": body}
if data:
payload["data"] = data
async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.post(url, headers=headers, json=payload)
res.raise_for_status()
return res.json()
def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
"""
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动)。
"""
if n <= 0:
return []
total = (end - start).total_seconds()
if total <= 0:
return []
out: list[datetime] = []
for i in range(n):
seg_start = start + timedelta(seconds=total * i / n)
seg_end = start + timedelta(seconds=total * (i + 1) / n)
seg = (seg_end - seg_start).total_seconds()
if seg <= 0:
out.append(seg_start)
continue
jitter = random.random() * seg
out.append(seg_start + timedelta(seconds=jitter))
return out
@dataclass(frozen=True)
class _ScheduleTarget:
local_date: date
start_local: datetime
end_local: datetime
def _pick_schedule_target(*, now_utc: datetime, tz: ZoneInfo) -> _ScheduleTarget:
"""
为某个用户选择“要生成计划的本地日期”:
- 若当前本地时间 < 09:00生成“今天”
- 否则:生成“明天”
目的:确保生成的时间点尽量都在未来,避免任务 ETA 立刻触发导致体验异常。
"""
now_local = now_utc.astimezone(tz)
today = now_local.date()
day = today if now_local.time() < time(9, 0) else (today + timedelta(days=1))
start_local = datetime.combine(day, time(9, 0), tzinfo=tz)
end_local = datetime.combine(day + timedelta(days=1), time(0, 0), tzinfo=tz) # 24:00
return _ScheduleTarget(local_date=day, start_local=start_local, end_local=end_local)
async def _generate_schedule_once(*, now_utc: datetime, max_users: int = 5000) -> dict[str, int]:
"""
为所有开启每日提醒的用户生成当天/明天的推送计划,并投递 ETA 发送任务。
幂等:
- `push_send_log` 唯一键client_user_id + local_date + slot_index保证同一天不会重复排程
- 即使重复运行,最多只会补齐缺失 slot
"""
created = 0
scheduled = 0
async with AsyncSessionLocal() as session:
q = (
select(PushPreference)
.where(PushPreference.enabled == True, PushPreference.times_per_day > 0) # noqa: E712
.limit(int(max_users))
)
rows = await session.execute(q)
prefs = list(rows.scalars().all())
for pref in prefs:
tz = _ensure_tz(pref.timezone)
target = _pick_schedule_target(now_utc=now_utc, tz=tz)
n = int(pref.times_per_day or 0)
n = max(0, min(5, n))
if n <= 0:
continue
times_local = _uniform_jitter_times(start=target.start_local, end=target.end_local, n=n)
for idx, dt_local in enumerate(times_local, start=1):
dt_utc = dt_local.astimezone(timezone.utc)
# 先尝试插入日志(幂等)
log = PushSendLog(
client_user_id=pref.client_user_id,
local_date=target.local_date,
slot_index=int(idx),
scheduled_at=dt_utc.replace(tzinfo=None), # DB 存 naive约定为 UTC
status="scheduled",
)
session.add(log)
try:
await session.commit()
except Exception:
await session.rollback()
# 可能已存在(唯一键冲突),跳过
continue
created += 1
# 投递 ETA 发送任务
current_app.send_task(
"tasks.push.send_scheduled",
kwargs={
"client_user_id": pref.client_user_id,
"local_date": target.local_date.isoformat(),
"slot_index": int(idx),
},
eta=dt_utc,
)
scheduled += 1
return {"created": created, "scheduled": scheduled}
@shared_task(name="tasks.push.generate_daily_schedule")
def generate_daily_schedule(*, max_users: int = 5000) -> dict[str, int]:
"""
生成每日推送计划(入口任务)。
说明:
- 建议由 celery beat 定时触发(见 app/worker.py
- 入参尽量小,避免 Redis 队列膨胀
"""
now_utc = datetime.now(timezone.utc)
return asyncio.run(_generate_schedule_once(now_utc=now_utc, max_users=int(max_users)))
async def _send_once_async(*, client_user_id: str, local_date: date, slot_index: int) -> dict[str, Any]:
async with AsyncSessionLocal() as session:
# 1) 查 log避免重复发送
qlog = select(PushSendLog).where(
PushSendLog.client_user_id == client_user_id,
PushSendLog.local_date == local_date,
PushSendLog.slot_index == int(slot_index),
)
rlog = await session.execute(qlog)
log = rlog.scalar_one_or_none()
if log is None:
return {"status": "noop", "reason": "no_log"}
if str(log.status) == "sent":
return {"status": "noop", "reason": "already_sent"}
# 2) 当前偏好检查(用户可能中途关闭/改次数)
qpref = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
rpref = await session.execute(qpref)
pref = rpref.scalar_one_or_none()
if pref is None or (not bool(pref.enabled)) or int(pref.times_per_day or 0) < int(slot_index):
log.status = "skipped"
log.error = "disabled_or_reduced"
await session.commit()
return {"status": "skipped"}
# 3) 找 token
qtok = (
select(PushToken)
.where(PushToken.client_user_id == client_user_id, PushToken.is_active == True) # noqa: E712
.order_by(PushToken.last_seen_at.desc())
.limit(1)
)
rtok = await session.execute(qtok)
token = rtok.scalar_one_or_none()
if token is None:
log.status = "failed"
log.error = "no_active_token"
await session.commit()
return {"status": "failed", "reason": "no_active_token"}
# 4) 生成文案(复用推荐模块 push 场景)
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
title = _pick_title(reco_locale)
if pref.user_profile_json:
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
else:
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
user_profile = UserProfileV1_2.model_validate(
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
)
# 直接复用 reco 的 Celery 任务实现(同步函数)
from app.tasks.reco import generate as reco_generate
reco_payload = reco_generate(scene="push", user_profile=user_profile.model_dump(), k=1, locale=reco_locale)
body = ""
try:
items = (reco_payload or {}).get("items") or []
if items and isinstance(items, list):
body = str(items[0].get("text") or "").strip()
except Exception:
body = ""
if not body:
body = "给自己一句温柔的话。"
# 5) 发送
try:
expo_res = await _send_expo_push(
to=str(token.push_token),
title=title,
body=body,
data={"client_user_id": client_user_id, "scene": "push"},
)
except Exception as e:
log.status = "failed"
log.error = f"send_failed:{type(e).__name__}"
await session.commit()
return {"status": "failed", "error": str(e)}
# 6) 解析 Expo 回执,必要时停用 token
try:
data_list = (expo_res or {}).get("data") or []
if data_list and isinstance(data_list, list):
first = data_list[0] or {}
if first.get("status") == "error":
details = first.get("details") or {}
err = str(details.get("error") or first.get("message") or "expo_error")
log.status = "failed"
log.error = err
if "DeviceNotRegistered" in err:
token.is_active = False
await session.commit()
return {"status": "failed", "expo": expo_res}
except Exception:
# 忽略解析异常,继续按成功处理
pass
log.status = "sent"
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
log.error = None
await session.commit()
return {"status": "sent", "expo": expo_res}
@shared_task(name="tasks.push.send_scheduled")
def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]:
"""
ETA 发送任务:发送某用户某天第 slot 条推送。
"""
d = date.fromisoformat(str(local_date))
return asyncio.run(_send_once_async(client_user_id=str(client_user_id), local_date=d, slot_index=int(slot_index)))

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from celery import Celery
from celery.schedules import crontab
from app.core.config import get_settings
@@ -38,6 +39,20 @@ celery_app.conf.update(
task_default_routing_key=f"{prefix}:celery",
)
# 自动发现任务app/tasks 下的 shared_task
celery_app.autodiscover_tasks(["app.tasks"])
# 定时任务Celery Beat
# 说明:
# - 每天运行一次“生成推送计划”,为所有开启每日提醒的用户生成当天/明天的随机抖动时间点,并投递 ETA 发送任务
# - 这里按 UTC 00:10 触发一次;具体时间可按运维习惯调整
celery_app.conf.timezone = "UTC"
celery_app.conf.beat_schedule = {
"push-generate-daily-schedule": {
"task": "tasks.push.generate_daily_schedule",
"schedule": crontab(minute=10, hour=0),
"kwargs": {"max_users": 5000},
"options": {"queue": f"{prefix}:celery"},
}
}
# 自动发现任务(约定:导入 app.tasks 触发其内部对子模块的显式导入)
celery_app.autodiscover_tasks(["app"])

View File

@@ -49,3 +49,17 @@ CELERY_BROKER_URL=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
# 推送Expo
# EXPO_ACCESS_TOKEN=
# 法务协议链接(由后端统一托管并下发给客户端)
# 说明:
# - 当前仅支持 EN / TC 两种语言(与客户端现状一致)
# - 不配置时:后端会自动使用内置协议页面(/v1/legal/privacy、/v1/legal/terms保证点击有内容
# - 线上建议配置为你们的官网/静态站点 HTTPS 链接,便于独立更新
#
# 可选英文EN
# LEGAL_PRIVACY_URL_EN=
# LEGAL_TERMS_URL_EN=
#
# 可选繁体TC
# LEGAL_PRIVACY_URL_TC=
# LEGAL_TERMS_URL_TC=

View File

@@ -0,0 +1,72 @@
import os
import unittest
class TestLegalLinksLangResolution(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
# 让 Settings 能顺利构造(避免因必填 env 缺失导致导入时报错)
os.environ.setdefault("DATABASE_URL", "mysql+aiomysql://u:p@127.0.0.1:3306/mindfulness_dev?charset=utf8mb4")
os.environ.setdefault("REDIS_URL", "redis://:p@127.0.0.1:6379/0")
os.environ.setdefault("CELERY_BROKER_URL", "redis://:p@127.0.0.1:6379/0")
def setUp(self) -> None:
# 每个用例都清理 settings 缓存,避免环境变量修改不生效
from app.core.config import get_settings
get_settings.cache_clear()
def test_resolve_lang_default_en(self) -> None:
from app.api.v1.legal import _resolve_lang
self.assertEqual(_resolve_lang(None), "en")
self.assertEqual(_resolve_lang(""), "en")
def test_resolve_lang_tc_for_zh(self) -> None:
from app.api.v1.legal import _resolve_lang
self.assertEqual(_resolve_lang("zh"), "tc")
self.assertEqual(_resolve_lang("zh-Hant"), "tc")
self.assertEqual(_resolve_lang("zh-TW"), "tc")
self.assertEqual(_resolve_lang("zh-HK"), "tc")
self.assertEqual(_resolve_lang("tc"), "tc")
def test_pick_urls_fallback_to_en(self) -> None:
# 不配置 tc 链接时,应回退到 en
os.environ["LEGAL_PRIVACY_URL_EN"] = "https://example.com/en-privacy"
os.environ["LEGAL_TERMS_URL_EN"] = "https://example.com/en-terms"
os.environ.pop("LEGAL_PRIVACY_URL_TC", None)
os.environ.pop("LEGAL_TERMS_URL_TC", None)
from app.core.config import get_settings
get_settings.cache_clear()
from app.api.v1.legal import _pick_urls
privacy, terms, resolved = _pick_urls("tc")
self.assertEqual(privacy, "https://example.com/en-privacy")
self.assertEqual(terms, "https://example.com/en-terms")
self.assertEqual(resolved, "en")
def test_pick_urls_tc_when_configured(self) -> None:
os.environ["LEGAL_PRIVACY_URL_EN"] = "https://example.com/en-privacy"
os.environ["LEGAL_TERMS_URL_EN"] = "https://example.com/en-terms"
os.environ["LEGAL_PRIVACY_URL_TC"] = "https://example.com/tc-privacy"
os.environ["LEGAL_TERMS_URL_TC"] = "https://example.com/tc-terms"
from app.core.config import get_settings
get_settings.cache_clear()
from app.api.v1.legal import _pick_urls
privacy, terms, resolved = _pick_urls("tc")
self.assertEqual(privacy, "https://example.com/tc-privacy")
self.assertEqual(terms, "https://example.com/tc-terms")
self.assertEqual(resolved, "tc")
if __name__ == "__main__":
unittest.main()

169
spec_kit/App Push/plan.md Normal file
View File

@@ -0,0 +1,169 @@
# App Push每日提醒推送客户端 + 后端Plan
> 阶段技术计划plan
>
> 依据:`spec_kit/App Push/spec.md`
---
## 1. 总体思路
本期以 **Expo PushExpo Push Token + Expo Push Service** 为推送通道,实现“每日提醒”闭环:
- **客户端**:负责
- 生成/持久化 `client_user_id`UUID
- 引导用户设置每日次数05可跳过
- 申请通知权限、获取 Expo Push Token、并上报后端
- 在个人主页的“每日提醒”弹窗修改次数或关闭,并同步到后端
- 上报 `timezone`IANA`locale`(用于文案语言)
- **后端**:负责
- 保存 token 绑定与用户推送偏好
- 每日按用户时区在 9:0024:00 窗口内生成 \(N\in[0,5]\) 个随机抖动时间点
- 在这些时间点向用户发送推送(使用后端 `@overview.md` 约定的个性化推荐模板/文案策略)
- 幂等防重复(同一用户同一天同一序号只发一次)
---
## 2. 客户端设计
### 2.1 UUIDclient_user_id
- 复用 `spec_kit/Client User Identity/spec.md` 的结论:
- 默认 UUID v4
- 本地稳定持久化
- 获取时机:
- App 启动即确保已生成(便于后续任何上报都可带上)
### 2.2 Push 权限与 token 获取策略
- 在 Onboarding 的“每日提醒”设置页(或 push 引导页)进行权限申请:
- 先说明 → 再弹系统权限框
- token 获取与上报触发:
- 权限 granted 后立即获取 token 并上报
- 后续冷启动时可“补偿上报”(避免首轮失败导致后端缺 token
### 2.3 每日提醒设置05 次)
#### Onboarding
- 新增或复用一个 Onboarding 步骤:
- 05 次选择
- “跳过”按钮(跳过不阻塞)
- 当用户选择 `times_per_day > 0`
- 引导用户开启系统通知权限
- 成功与否都进入主功能
#### 个人主页弹窗Daily Reminder
- 现有弹窗:
- 次数 +/-(限制 05
- 开关(关闭等价 `times_per_day=0`
- 权限 denied给出清晰提示
- 修复:移除/关闭“测试模式强制无权限”逻辑(该逻辑会导致永远表现为无法开启)
### 2.4 客户端与后端接口调用
- 新增 `client/src/services/pushApi.ts`(参考现有 `legalApi.ts` / `recoApi.ts` 的风格),并使用统一 HTTP 封装 `client/src/utils/http.ts`
- 接口调用点:
- register拿到 token 后
- preferences用户完成选择或在个人主页修改后
---
## 3. 后端设计
### 3.1 数据模型(最小可用)
1) `push_tokens`
- 唯一键:`env + app_id + push_token`
- 字段:
- `client_user_id`UUID string
- `platform`ios/android
- `push_token`
- `app_id`
- `env`
- `is_active`
- `last_seen_at`
2) `push_preferences`
- 主键:`client_user_id`(或加 `env + app_id` 做隔离)
- 字段:
- `enabled`
- `times_per_day`05
- `timezone`IANA来自客户端
- `locale`(用于文案选择)
- `updated_at`
3) `push_send_log`(幂等防重复)
- 目的:保证“同一用户同一天第 k 条只发一次”
- 唯一键:
- `client_user_id + local_date + slot_index`slot_index=1..times_per_day
- 字段:
- `scheduled_at`(用户时区的时间点)
- `sent_at`
- `status`scheduled/sent/failed
- `error`(可选)
> 注:不直接对数据库执行破坏性操作;通过迁移脚本落地表结构,执行迁移需你显式允许后才进行。
### 3.2 API
`spec_kit/App Push/spec.md`
- `POST /v1/push/register`
- `PUT /v1/push/preferences`
- `GET /v1/push/preferences`
- `POST /v1/push/test`(仅 dev
### 3.3 推送发送器Expo
- 使用 `https://exp.host/--/api/v2/push/send`
- 最小 payload
- `to`Expo Push Token
- `title/body`:来自个性化推荐模板
- `data`:深链路参数(可选)
- 失败处理:
- 对 “DeviceNotRegistered”等不可恢复错误将 token 标记为 inactive
- 记录失败原因到 `push_send_log`
### 3.4 定时任务(每日 924随机抖动
策略:**每日“生成当天计划 + 分发 ETA 任务”**(推荐)
- 每日固定时刻(例如 UTC 00:10 或服务器本地时间某个点)执行一次“生成计划任务”:
- 对每个 `enabled && times_per_day>0` 的用户:
- 将用户时区当天的 9:0024:00 转换为 UTC
- 随机生成 \(N\) 个时间点:
- 均匀切分窗口为 \(N\) 个区间
- 每个区间内随机选一个时间(抖动)
- 对每个 slot 写入 `push_send_log`(唯一键保证幂等)
- 再投递 Celery ETA 任务,到点调用“发送 push”
这样可以避免 worker 长时间 sleep也便于观察“今天会推哪些”。
---
## 4. 个性化推荐模板(后端 @overview.md
推送文案生成使用后端推荐模块中 “Push 场景模板/策略”:
- 输入:`client_user_id`、(可选)用户画像/行为统计
- 输出:`title/body`(多语言)
- 风险控制Push 场景默认“降个性化/降风险”,避免敏感/医疗类文案(遵守后端已定义的安全规则)
> plan 阶段仅明确“调用点与输入输出”,具体实现复用后端推荐模块现有能力,避免重复逻辑。
---
## 5. 测试与验收(落地检查清单)
- 客户端:
- 首次进入可选择次数/跳过
- 权限 granted 时能拿到 Expo token 并上报
- 设置页改次数/关闭会调用 preferences 接口并持久化
- 后端:
- register/preferences 接口幂等可重试
- test 接口可在 dev 环境立刻推送到真机
- 每日计划生成不会重复排程(`push_send_log` 唯一键)
- token 失效可自动停用

220
spec_kit/App Push/spec.md Normal file
View File

@@ -0,0 +1,220 @@
# App Push每日提醒推送客户端 + 后端Spec
> 阶段高层规范spec
>
> 目标:实现“每日提醒”推送闭环:用户在首次进入 Onboarding 可选择每天推送次数05可跳过并可在个人主页的“每日提醒”弹窗随时调整次数或关闭客户端使用首次生成的 UUID 作为用户标识与后端关联;后端按用户配置定时下发推送。
---
## 1. 背景与动机(摘要)
当前客户端已有 Push 引导页(可跳过)与“每日提醒”设置入口,但尚未形成完整闭环:
- 需要一个稳定的匿名用户标识UUID将“提醒配置”与“推送 token”绑定到后端
- 需要后端能够按用户选择的每日次数05进行定时推送
- 本期不追求高级推送能力(富媒体、复杂分群、到达率分析等)
---
## 2. 目标Goals
- **G1Onboarding 选择**:用户首次进入 Onboarding 可设置“每日提醒次数”05可跳过且不阻塞进入主功能。
- **G2设置页可改可关**用户可在个人主页的“每日提醒”弹窗调整次数05或关闭推送。
- **G3UUID 关联**:客户端首次进入生成 `client_user_id`UUID用于与后端关联用户配置与推送 token`spec_kit/Client User Identity/spec.md`)。
- **G4推送闭环**:客户端拿到推送权限与 Push Token 后上报后端;后端存储并按计划每日推送。
- **G5幂等与可恢复**重复上报、token 轮换、用户反复开关不应导致重复推送或“僵尸任务”。
- **G6多语言**:推送文案至少支持当前客户端语言体系(`zh-CN/en/es/pt/zh-TW`),或以安全默认语言回退。
---
## 3. 非目标Non-goals
- 不接入高级推送能力(富媒体、通知分类/动作按钮、复杂分群、A/B 实验、到达率看板等)。
- 不引入账号体系与“多设备同人合并”策略(未来可基于 `account_id` 扩展)。
- 不在本阶段做精细化内容策略(例如千人千面的复杂推荐推送);仅保证“按次数定时发送”与文案安全合规。
---
## 4. 技术选型(高层)
### 4.1 客户端
- 继续使用 **Expo**`expo-notifications`
- Push 权限申请:通过引导页解释后再触发系统弹窗(降低拒绝率)
- Token 获取:使用 Expo Push Token后续计划阶段细化获取与上报时机
### 4.2 后端
- 推送通道:**Expo Push Service**(与客户端 `expo-notifications` 配套)
- 定时任务使用现有后端技术栈约定Celery + Redis / 或等价调度器),每日按用户时区/策略触发推送
> 说明:若未来需要更高上限(自建 APNs/FCM 直连),可在后续模块升级,不影响本期 API 字段语义(`client_user_id`、`push_token`、偏好配置)的大框架。
---
## 5. 用户流程User Flows
### 5.1 首次进入Onboarding
1. App 首次启动 → 进入 Onboarding 流程
2. 在 Onboarding 某一页提供“每日提醒次数”选择05
- 0 表示不接收每日提醒(等同关闭)
- 可点击“跳过”(不设置/不打扰)
3. 若用户选择次数 > 0
- 展示 Push 引导说明页
- 用户点击“立即开启”触发系统权限申请
4. 无论是否开启成功,均可进入主功能(不阻塞)
### 5.2 个人主页(每日提醒弹窗)
- 用户可:
- 调整每日次数05
- 开启/关闭提醒(关闭等价次数=0
- 若系统权限为 denied提示用户前往系统设置开启不做强制跳转要求按平台能力实现
### 5.3 后端推送执行
- 后端按用户配置与时区,在每日窗口内发送 \(N\) 次推送(\(N\in[0,5]\)
- 若用户关闭或次数=0不再推送
---
## 6. 数据与持久化(高层)
### 6.1 客户端本地存储(最小集合)
- `client_user_id`: stringUUID稳定持久化见用户标识 spec
- `push.permission_state`: `unknown | granted | denied`(用于 UI 显示与引导)
- `daily_reminder.enabled`: boolean可选或由次数是否为 0 推导)
- `daily_reminder.times_per_day`: number05
- `daily_reminder.updated_at`: ISO string可选用于排障/幂等)
### 6.2 后端持久化(逻辑约束)
最小需要表达两类信息:
1) **设备/Token 绑定**
- `client_user_id`
- `platform`ios/android
- `push_token`Expo Push Token
- `app_id`bundle id / package name
- `env`dev/prod
- `last_seen_at`
- `is_active`
2) **用户推送偏好**
- `client_user_id`
- `enabled`
- `times_per_day`05
- `timezone`(建议 IANA`Asia/Shanghai`;若拿不到则回退为服务器默认策略)
- `locale`(用于推送文案语言选择;可从客户端上报或推送时推断)
- `updated_at`
---
## 7. API 契约(高层)
> 说明:路由名可在 plan 阶段对齐现有服务结构;此处先固定“字段语义 + 幂等行为”。
### 7.1 注册/更新 Push Token幂等
- `POST /v1/push/register`
- 请求体(最小集,继承用户标识 spec
- `client_user_id`: stringUUID
- `platform`: `"ios" | "android"`
- `push_token`: stringExpo Push Token
- `app_id`: string
- `env`: `"dev" | "prod"`
- `device_meta`(可选):`{ model, os_version, app_version, locale, timezone }`
- 行为:
- 幂等:重复上报同一 token 不产生多条“有效绑定”
- token 变更:同一 `client_user_id` 上报新 token 后,后端应更新“当前有效 token”
### 7.2 设置每日提醒偏好(幂等)
- `PUT /v1/push/preferences`
- 请求体:
- `client_user_id`: string
- `enabled`: boolean
- `times_per_day`: number05若 enabled=false 则可强制视为 0
- `timezone`: string可选
- `locale`: string可选
- 行为:
- 幂等:相同配置重复提交不改变结果
- `enabled=false``times_per_day=0`:必须停止后续推送(不再产生新的发送任务)
### 7.3 查询当前偏好(可选但建议)
- `GET /v1/push/preferences?client_user_id=...`
- 返回:
- `enabled`
- `times_per_day`
- `timezone`
- `locale`
- `updated_at`
### 7.4 立即测试推送(仅 dev可选
- `POST /v1/push/test`
- 请求体:
- `client_user_id`
- `title` / `body`(可选)
- 用途联调排障token 绑定、证书/通道、Expo 配置)
---
## 8. 推送策略(高层)
### 8.1 发送次数与窗口
- 每日发送次数05
- 建议定义“允许发送的时间窗口”(例如 09:0021:00避免深夜打扰
-`times_per_day > 0` 时,在窗口内生成 \(N\) 个时间点并发送
> 时间点生成策略(均匀分布/固定时刻/随机抖动)在 plan 阶段确定;本期只要求“次数正确、用户可控、不会超发”。
### 8.2 文案来源与语言
- 文案可先采用后端配置的模板(按 `locale` 选择,失败回退 `en``zh-CN`
- 若未来要接入推荐系统,可在后续迭代按用户画像生成更个性化文案(不在本期范围)
---
## 9. 边界场景与处理原则
- **用户跳过 Onboarding**:不强制开启;可在个人主页再次设置。
- **系统权限 denied**:客户端提示引导去系统设置;后端保存偏好但不保证可推送(无有效 token 时不发送)。
- **token 缺失/过期**:后端发送失败时应标记 token 为不可用,并等待客户端下次上报更新。
- **重复开关/改次数**:后端必须幂等更新,避免同一用户一天内重复排程导致超发。
- **环境隔离**dev/prod 的 token 与配置必须隔离(`env + app_id` 维度)。
- **卸载/重装**`client_user_id` 可能变化;视为新用户实例(符合匿名策略)。
---
## 10. 验收标准Acceptance Criteria
- 首次进入 Onboarding
- 用户可选择每日次数05或跳过
- 选择后不阻塞进入主功能
- 个人主页每日提醒弹窗:
- 可设置次数05与关闭
- 权限 denied 时有明确提示
- 客户端:
- `client_user_id` 稳定持久化
- 在获得权限与 token 后能上报后端(幂等)
- 修改偏好会同步到后端(幂等)
- 后端:
- 能保存 token 与偏好
- 能按用户配置每日推送(次数正确、不会超发)
- token 失效可被识别并停止对失效 token 推送
---
## 11. 待确认问题清单(进入 plan/tasks 前必须确认)
1. **“05 次”的含义**:是否严格表示“每天发送 05 条通知”(看起来是),还是“提醒强度档位”?
2. **发送时间策略**:默认窗口与时间点生成方式(固定时刻 vs 均匀分布 vs 随机抖动)选哪一种?
3. **时区来源**:以客户端上报的 IANA 时区为准吗?若缺失回退到什么策略?
4. **推送文案**:本期文案是否完全由后端模板控制(便于随时调整),还是前端上报“文案 key/参数”由后端拼装?
5. **Push 允许发送的静默规则**:是否需要“勿扰时间段/睡眠模式”开关(例如 22:0008:00 不推)?

127
spec_kit/App Push/tasks.md Normal file
View File

@@ -0,0 +1,127 @@
# App Push每日提醒推送客户端 + 后端Tasks
> 阶段任务清单tasks
>
> 依赖:`spec_kit/App Push/spec.md`、`spec_kit/App Push/plan.md`
---
## 0. 约束与共识(本期已确认)
- 每日次数:**05 条/天**
- 发送窗口:**9:0024:00按客户端上报时区**
- 时间点:窗口内 **随机抖动**
- 勿扰:**不做**
- 文案:使用后端推荐模块的 **Push 场景个性化模板(降风险)**
---
## 1. 客户端Expo RN
### 1.1 UUIDclient_user_id
- [ ]`client/src/storage/appStorage.ts`(或新模块)实现:
- `getOrCreateClientUserId(): Promise<string>`
- 首次生成 UUID 并持久化,后续复用
### 1.2 Push 权限与 token
- [ ] 接入获取 Expo Push Token 的封装(例如 `src/features/push/`
- 获取权限状态
- 请求权限
- 获取 Expo Push Token
- [ ] 在 Onboarding / push 引导完成后:
- granted 时:获取 token + 调 `register`
- 未 granted仅保存本地设置不阻塞进入主功能
### 1.3 “每日提醒次数”入口
- [ ] Onboarding新增/复用一个步骤页面
- 选择 05 次0 表示关闭)
- 支持跳过
- 完成后写本地存储,并调用后端 `preferences`
- [ ] 个人主页弹窗:
- 修复“测试模式强制无权限”的逻辑
- 次数限制 05关闭等价 0
- denied 时提示去系统设置
- 修改后写本地存储,并调用后端 `preferences`
### 1.4 接口封装
- [ ] 新增 `client/src/services/pushApi.ts`
- `registerPushToken(...)`
- `setPushPreferences(...)`
- `getPushPreferences(...)`(可选)
- [ ] 统一使用 `client/src/utils/http.ts` 的请求封装
### 1.5 联调开关与日志
- [ ] 开发环境输出必要日志(不打印敏感信息):
- client_user_id 生成结果
- 权限状态与 token 是否获取成功
- register/preferences 的请求是否成功与错误原因
---
## 2. 后端FastAPI
### 2.1 数据模型与迁移
- [ ] 新增表(或等价模型):
- `push_tokens`
- `push_preferences`
- `push_send_log`(幂等防重复)
- [ ] 增加迁移脚本Alembic 或项目现有迁移机制)
> 注意:不执行任何破坏性数据库操作;运行迁移前若涉及真实库,需要你明确回复“允许操作数据库”。
### 2.2 API
- [ ] `POST /v1/push/register`
- 幂等:`env + app_id + push_token` 唯一
- 更新 `client_user_id` 归属与 `last_seen_at`
- [ ] `PUT /v1/push/preferences`
- 校验 `times_per_day` ∈ [0,5]
- `enabled=false``times_per_day=0` 时停止后续推送
- [ ] `GET /v1/push/preferences`
- 返回当前偏好与更新时间
- [ ] `POST /v1/push/test`(仅 dev
- 立即向该用户发送一条测试推送(用于真机联调)
### 2.3 Expo 推送发送器
- [ ] 实现 `send_expo_push(token, title, body, data?)`
- 处理 Expo 返回错误并对不可恢复错误停用 token
- 记录发送结果到 `push_send_log`
### 2.4 推送文案(推荐模板)
- [ ] 在推送任务中调用后端推荐模块的 Push 场景模板:
- 输入:`client_user_id`、语言/时区(可选)
- 输出:`title/body`
- 默认“降个性化/降风险”
---
## 3. 定时任务(每日计划 + ETA 发送)
- [ ] 每日“计划生成任务”
- 扫描 `enabled && times_per_day>0` 用户
- 按用户时区在 9:0024:00 生成 N 个随机抖动时间点
- 写入 `push_send_log`(唯一键保证幂等)
- 投递 ETA 发送任务(或按项目现有任务系统实现)
- [ ] ETA “发送任务”
- 拉取当次发送所需 token/偏好
- 生成文案(推荐模板)
- 调用 Expo push 发送
- 更新 `push_send_log` 状态
---
## 4. 验收与回归
- [ ] iOS 真机权限申请、token 获取、test 推送可达
- [ ] Android 真机权限申请、token 获取、test 推送可达
- [ ] 修改次数:后端计划生成正确(一天内不超发)
- [ ] 关闭:后端停止后续推送(不再生成计划/不再发送)

View File

@@ -0,0 +1,24 @@
# Daily Widget Reco补充说明 / Overflow
## 1. 关键配置
- **App Group suiteName**`group.com.damer.mindfulness`
- **后端鉴权**:无
- **多语言**:仅 `en / tc`
## 2. 共享存储 KeyApp ↔ Widget
- `widget.config.v1`
- 字段:`schema_version=1``saved_at``apiBaseUrl`
- `widget.userProfile.v1_2`
- 字段:`schema_version=1``saved_at``user_profile`(结构对齐 `UserProfileV1_2`
- `widget.dailyReco.v1`
- 字段:`schema_version=1``saved_at``day_key`(本地日 `YYYY-MM-DD`)、`lang``item{content_id,text}``source`
## 3. 更新策略(双通道)
- **Widget 主动拉取**:缓存过期时请求 `POST /v1/reco/widget`,写回 `widget.dailyReco.v1`,并使用 `TimelinePolicy.after` 设定次日刷新
- **App 辅助刷新**
- 启动与回到前台时调用 `ensureDailyWidgetRecoUpToDate`
- 成功写入缓存后调用 `WidgetCenter.reloadAllTimelines()`(系统仍可能延迟)

View File

@@ -0,0 +1,176 @@
# Daily Widget Reco技术计划
> 对应规范:`spec_kit/Daily Widget Reco/spec.md`
>
> 已确认输入(来自澄清):
>
> - 更新责任:**App 与 Widget 都需要**双通道Widget 主动拉取 + App 辅助刷新)
> - App Group suiteName`group.com.damer.mindfulness`
> - 后端鉴权:不需要
> - baseURL由 **App 写入共享区** 提供给 Widget
> - “每日”口径:用户本地时区
> - 画像来源App 写入共享区Onboarding 完成后写入)
---
## 1. 计划目标
- 打通 `POST /v1/reco/widget`,让客户端与 Widget 都能获取每日推荐Top1
- 通过 App Group 共享存储,保证 **App 与 Widget 当天展示一致**
- 实现“每日更新(尽力而为)”:用户添加小组件后,即使不打开 App也能依赖 WidgetKit Timeline 在每日范围内刷新。
- 完整回退链路:今日缓存 → 最近缓存 → 兜底文案;任何失败不 crash、不阻塞主流程。
---
## 2. 总体方案(双通道更新)
### 2.1 核心结论
- **Widget 主动拉取**是满足“添加小组件后每日更新”的必要条件(仅靠 App 前台触发无法覆盖用户不打开 App 的情况)。
- 同时保留 **App 辅助拉取与 reload**
- App 启动/进入前台/画像更新后可主动拉取并写缓存,提升即时性与一致性。
- App 在写入共享缓存后调用 `WidgetCenter.reloadAllTimelines()`,加速 Widget 读取到新数据(系统仍可能延迟)。
### 2.2 数据流(高层)
- AppRN
- 写入共享配置(`apiBaseUrl` 等)→ 写入共享画像 →(可选)拉取 `widget` 推荐 → 写入共享每日缓存 → reload Widget
- WidgetSwift
- 读取共享每日缓存,若过期则读取共享配置+画像并请求后端 → 写入共享缓存 → 输出 timeline
---
## 3. 客户端React Native / Expo改动点
### 3.1 新增 Widget 场景 API 封装
-`client/src/services/recoApi.ts` 新增:
- `fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult>`
- Header `Accept-Language` 逻辑沿用 Feed`zh* -> tc` else `en`
- path`/v1/reco/widget`
### 3.2 共享存储:新增 App Group 写入能力
> 目标RN 侧将必要数据写入 `UserDefaults(suiteName: "group.com.damer.mindfulness")`,供 Widget 读取。
建议实现路径(两种任选其一,按当前工程实际选型落地):
- 方案 A推荐引入一个“App Group UserDefaults 读写”的 RN 原生桥/插件Swift/ObjC 模块 + JS 封装)。
- 方案 B若项目已存在可用能力例如已接入 `react-native-shared-group-preferences` 或自研模块),直接复用并统一 key。
需要写入的共享 key与 spec 对齐):
- `widget.config.v1`:包含 `apiBaseUrl`(以及未来灰度字段)
- `widget.userProfile.v1_2`用户画像快照JSON
- `widget.dailyReco.v1`每日推荐缓存JSON
### 3.3 App 侧写入与刷新触发时机
- **画像写入**
- Onboarding 完成后(或画像更新后)写入 `widget.userProfile.v1_2`
- **配置写入**
- App 启动时(或 `API_BASE_URL` 计算完成后)写入 `widget.config.v1`
- **主动拉取每日推荐(可选但建议)**
- App 启动或进入前台时:
- 读取共享 `widget.dailyReco.v1`,若 `day_key` 不是今天则调用 `fetchRecoWidget` 拉取 Top1
- 成功写入共享缓存,失败不影响 UI
- **触发 Widget 刷新**
- 当 App 写入 `widget.dailyReco.v1` 成功后,调用 `WidgetCenter.reloadAllTimelines()`(通过原生桥触发)
> day_key 计算:以用户本地时区生成 `YYYY-MM-DD`(例如 `2026-02-03`)。
---
## 4. iOS WidgetSwift / WidgetKit改动点
### 4.1 共享存储读取
- 统一使用:
- `UserDefaults(suiteName: "group.com.damer.mindfulness")`
- 读取并解析:
- `widget.dailyReco.v1`
- `widget.userProfile.v1_2`
- `widget.config.v1`
解析失败必须容错:视为缺失,走回退策略。
### 4.2 Timeline 刷新策略(每日)
- `getTimeline` 流程:
1.`widget.dailyReco.v1`
2.`day_key` 为今天且文案存在 → 直接出 timeline
3. 否则尝试网络拉取(需要 config + userProfile 均存在):
- 请求 `POST {apiBaseUrl}/v1/reco/widget`
- `Accept-Language`:从缓存 lang 或系统语言映射到 `en/tc`(优先与 App 一致)
- `k=1`
4. 成功:写入共享缓存并出 timeline
5. 失败:用“最近缓存/兜底文案”出 timeline
- `policy`
- `TimelinePolicy.after(nextRefreshDate)`
- `nextRefreshDate`:下一天本地时间的一个随机刷新点(例如 00:1001:00 随机),减少集中刷新与被系统限流概率
### 4.3 网络实现注意事项
- 使用 `URLSession`,超时建议 812 秒(与 RN 对齐即可)
- 无鉴权
- 失败不抛出 crash打印有限日志或仅在 Debug
---
## 5. 共享数据结构与一致性规则
### 5.1 `widget.dailyReco.v1`v1
`spec.md` 定义字段,重点:
- `day_key`:本地日 key用户时区
- `lang``en|tc`
- `item.content_id + item.text`Widget 展示必须字段
- `source`:标记由 `app` 还是 `widget` 写入(便于排查)
### 5.2 一致性策略(同一天同一条)
规则:
- Widget 展示以 `widget.dailyReco.v1` 为准(共享缓存是单一事实来源)。
- App 若拉取到新结果,应覆盖写入共享缓存,并触发 reload保证 Widget 同步。
- 若当天出现“不同条”风险(并发写入):
- 以最后写入者为准last-write-wins
- 通过 `saved_at` + `source` 辅助排查
---
## 6. 回退策略(必须实现)
- 今日缓存存在且有效 → 展示
- 今日缓存无效但有最近缓存 → 展示最近缓存
- 无任何缓存 → 展示兜底安全文案(写死)
同时:
- 后端返回 `items=[]` 视为失败
- JSON 解析失败视为无缓存
---
## 7. 实施顺序(建议)
1. **补齐客户端 API**:新增 `fetchRecoWidget`,并在本地可通过 Postman/或 RN 调用验证返回。
2. **落地 App Group 共享读写RN → iOS**:先能写入/读取一个测试 key确保 suiteName 正确。
3. **写入共享配置与画像**:保证 Widget 侧具备发起请求所需输入。
4. **Widget 侧读取缓存并展示**:先用共享缓存驱动 UI不接网络也能跑通
5. **Widget 侧网络拉取 + 写回缓存**:实现每日更新主链路。
6. **App 辅助刷新**App 前台拉取(可选)+ 写缓存 + reloadAllTimelines。
---
## 8. 验收与测试建议
- **联调验收**
- 手动清空共享缓存 → 添加 Widget → 观察首次拉取并展示(或先展示兜底再更新)
- 修改设备日期到次日(或模拟 day_key 变化)→ 触发 `getTimeline`,确认会重新拉取
- 断网 → 应展示最近缓存/兜底文案,不崩溃
- **一致性验收**
- App 拉取后写入共享缓存Widget 在 reload 后显示同一条

View File

@@ -0,0 +1,197 @@
# Daily Widget Reco高层规范
> 阶段高层规范spec
>
> 背景引用:
>
> - `spec_kit/Personalized Reco/overview.md`后端已实现可复用推荐流程Feed / Push / Widget
> - `spec_kit/iOS Widget/spec.md`:已完成 WidgetKit Extension V1写死文案
>
> 本需求聚焦:**iOS 小组件“每日推荐”与 App 数据共通**,以及**客户端与后端推荐接口的串联**。
---
## 1. 背景与动机
后端已实现推荐接口(含 `widget` 场景),但客户端目前只接入了 Feed 推荐(`POST /v1/reco/feed`iOS 小组件仍是 V1 写死文案。
现在需要实现:
- iOS 小组件展示“每日推荐”(每天一条),从后端获取。
- App 与 Widget 的推荐内容数据共通(同一天展示同一条),并支持在用户添加小组件后开始每日更新。
- 推荐链路具备可回退能力:网络失败、画像缺失、接口异常时不崩溃、仍有可展示内容。
---
## 2. 目标Goals
- **后端串联**:客户端与 iOS WidgetKit 能稳定调用后端 `widget` 场景推荐接口获取每日文案。
- **数据共通**App 与 Widget 通过 **App Group 共享存储**读写同一份“每日推荐缓存”,保证展示一致。
- **每日更新(尽力而为)**
- 小组件添加到桌面后,系统开始调度 Widget TimelineWidget 能在**每天**刷新一次内容(受系统限制,允许延迟)。
- App 在合适时机触发更新与 `WidgetCenter.reload`,提高准时性(但不承诺“严格准点”)。
- **安全与回退**:出现失败时按回退梯度展示(今日缓存 → 最近一次缓存 → 兜底安全文案),且不阻塞 App 主流程。
---
## 3. 非目标Non-goals
- 不做 Android Widget。
- 不在本需求内改动后端推荐算法逻辑与 DB后端已实现接口
- 不承诺高频刷新(例如分钟级),遵循 iOS WidgetKit 刷新限制。
- 不在本需求内实现复杂的“用户自定义小组件配置”(例如选择主题/类型);仅做“每日一句”。
---
## 4. 范围与交付物
### 4.1 范围
- 客户端React Native / Expo
- 增加 `POST /v1/reco/widget` 的请求封装(与 Feed 形态一致)。
- 负责把“用户画像快照/必要上下文”写入 App Group供 Widget 拉取/请求)。
- 在合适时机触发“写缓存 + 通知 Widget 刷新”。
- iOS WidgetSwift / WidgetKit Extension
- 从 App Group 读取“每日推荐缓存”并展示。
- 在需要时(缓存过期/缺失)发起网络请求从后端拉取,并写回缓存(或由 App 拉取并写回,见第 8 节决策)。
- 共享数据契约:
- 定义 App 与 Widget 共用的数据结构(版本化、可演进)。
### 4.2 交付物
- 客户端:推荐接口封装 + 共享存储写入 + 刷新触发。
- iOSWidget Timeline Provider 支持每日推荐(读取共享缓存/必要时拉取)。
- 文档:本 `spec.md`
---
## 5. 术语与关键约束
- **Daily Reco每日推荐**Widget 场景 Top1 文案(每天一条)。
- **App Group**iOS 用于 App 与 Extension 共享数据的机制(建议使用共享 `UserDefaults(suiteName:)`)。
- **Widget 刷新限制**:刷新由系统调度,**不可保证准点**;需要设计缓存与兜底展示。
---
## 6. 后端接口契约Client ↔ Server
### 6.1 接口
- `POST /v1/reco/widget`
### 6.2 Header
- `Accept-Language`
- 客户端沿用既有策略:当前仅区分 `en/tc`
- 规则:`i18n.language``zh` 开头 → `tc`,否则 `en`
### 6.3 Request Body与 Feed 一致)
- `k?: number`Widget 默认 1建议不传或显式传 `1`
- `user_profile: UserProfileV1_2`(必填)
- `already_recommended_ids?: (string|number)[]`(可选,默认 `[]`
- `touched_or_viewed_ids?: (string|number)[]`(可选,默认 `[]`
- `now?: string`ISO8601可选用于测试/确定性)
### 6.4 Response Body
- `items: RecommendedItem[]`Widget 预期取 `items[0]`;允许为空)
- `meta: Record<string, unknown>`(用于可观测/调参;客户端可选择性落地)
---
## 7. 共享数据契约App ↔ Widget
> 原则:结构**版本化**、字段可缺省、读取端容错;避免把整个业务状态塞进共享区。
### 7.1 共享 Key建议
- `widget.dailyReco.v1`每日推荐缓存Widget 展示的主数据)
- `widget.userProfile.v1_2`:用户画像快照(供 Widget 发起请求使用)
- `widget.recoHistory.v1`:去重/频控所需的最小历史(可选,控制大小)
- `widget.config.v1`Widget 端需要的配置(例如 `apiBaseUrl`、灰度开关等)
### 7.2 `widget.dailyReco.v1` 数据结构JSON
- `schema_version: 1`
- `saved_at: string`ISO8601
- `day_key: string`(本地日维度 key例如 `2026-02-03`;用于“今天是否已刷新”的判断)
- `lang: "en" | "tc"`
- `item`(可为空):
- `content_id: number`
- `text: string`
- `final_score?: number`
- `fallback_level_final?: number`
- `meta?: Record<string, unknown>`(可选,用于排查;注意体积)
- `source: "app" | "widget"`(可选:谁写入的)
### 7.3 读写规则
- Widget 展示优先级:
1. `day_key` 为今天且 `item.text` 非空 → 直接展示
2. 否则展示最近一次缓存(若存在)
3. 否则展示兜底安全文案(写死)
- 写入要求:
- 写入必须原子化(一次性写完整 JSON避免部分字段缺失导致解析失败
- 解析失败时当作“无缓存”,走兜底策略
---
## 8. 每日更新策略(刷新触发与责任划分)
### 8.1 关键结论
为满足“用户添加小组件后可每日更新”,需要依赖 WidgetKit 自身的 Timeline 调度。仅靠 App 在前台触发更新,无法保证用户不打开 App 时也能更新。
### 8.2 推荐实现V1.5Widget 主动拉取)
- **Widget Timeline Provider**
- 每次 `getTimeline`
- 先读 `widget.dailyReco.v1`
-`day_key` 不是今天 → 尝试从后端请求 `POST /v1/reco/widget` 获取今日内容
- 成功后写入 `widget.dailyReco.v1` 并返回 timeline
- 失败则返回“最近一次缓存/兜底文案”的 timeline
- Timeline 刷新策略:
- `policy = .after(nextRefreshDate)``nextRefreshDate` 设为“下一天的本地 00:1001:00 之间随机一个时间”(减少集中刷新)
- **AppRN**
- 在 Onboarding 完成或画像更新时,把 `widget.userProfile.v1_2` 写入共享区
- 当 App 成功拉取到推荐(可选)时,也可写入 `widget.dailyReco.v1` 并触发 `WidgetCenter.reloadAllTimelines()`,提升即时性
### 8.3 备选实现App 主动拉取 + Widget 仅展示)
若团队希望 Widget 端完全不发网络请求,则:
- App 负责在“启动/前台/每天首次打开”等时机拉取 `POST /v1/reco/widget` 并写入共享缓存
- Widget 仅读取缓存展示
缺点:用户不打开 App 时,小组件可能长期不更新(不满足“每日更新”的强诉求)
---
## 9. 失败回退与稳定性
- **网络失败**:使用最近一次缓存;若无缓存,展示兜底安全文案。
- **接口返回 items 为空**:视为失败,走同样回退。
- **画像缺失**
- App 侧应尽量在首次进入完成问卷后写入 `widget.userProfile.v1_2`
- 若仍缺失Widget 端不崩溃,直接展示缓存/兜底文案;并在下一次 timeline 继续尝试
- **数据损坏/解析失败**:清空本次读取结果,走兜底策略(不可 crash
---
## 10. 验收标准Acceptance Criteria
- **接口串联**:客户端/Widget 能成功请求 `POST /v1/reco/widget` 并解析返回。
- **数据共通**同一天内App 与 Widget 展示的“每日推荐”一致(以共享缓存为准)。
- **每日更新**:用户把小组件添加到桌面后,小组件在后续每天能更新到新的推荐(允许系统延迟,但应在一天内更新)。
- **可用性**:断网/后端不可用/返回异常时Widget 仍能展示最近缓存或兜底文案App 不崩溃。
---
## 11. 风险与注意事项
- iOS Widget 刷新由系统调度,无法承诺“严格 00:00 更新”;需接受“尽力而为 + 缓存兜底”。
- App Group 的 suiteName / entitlements 配置错误会导致 Widget 读不到共享数据;必须在实现阶段统一校验。
- Widget 端直连后端需要维护 baseURL 与环境切换策略dev/pro建议通过 `widget.config.v1` 注入,避免写死。

View File

@@ -0,0 +1,144 @@
# Daily Widget Reco任务清单
> 对应计划:`spec_kit/Daily Widget Reco/plan.md`
>
> 标记规则:
>
> - 执行完成后将对应项从 `- [ ]` 改为 `- [x]`,并把“状态”改为 **已完成**
> - 进行中改为 **进行中**
> - 阻塞写明原因与解除条件
---
## 0. 清单状态说明
- **状态**:未开始 / 进行中 / 已完成 / 阻塞
- **阻塞**:必须写明阻塞点与解除条件
---
## 1. 客户端:补齐 Widget 场景 APIRN
- [ ] **新增 `fetchRecoWidget``POST /v1/reco/widget`**
- **状态**:未开始
- **文件**`client/src/services/recoApi.ts`
- **要求**
- Header `Accept-Language` 逻辑沿用 `fetchRecoFeed`
- body 结构沿用 `RecoRequest`
- timeout 建议 12s
- **验收**:在 App 内调用可拿到 `items[0].text`(允许为空但不报错)
---
## 2. iOS 原生能力App Group 共享读写suiteName 已确认)
> suiteName`group.com.damer.mindfulness`
- [ ] **新增 App Group 共享存储原生模块RN Bridge**
- **状态**:未开始
- **目标**RN 可写/读共享 `UserDefaults(suiteName:)`
- **要求**
- 支持 `setString(key, value)` / `getString(key)`(最小闭环)
- 以 JSON 字符串形式存储(上层 JS 自行 `JSON.stringify/parse`
- **验收**
- RN 写入后Widget 侧能读到同一 key
- 解析失败不 crash返回 `null`/空字符串均可,按上层回退)
- [ ] **新增触发 Widget 刷新的原生方法(可选但建议)**
- **状态**:未开始
- **目标**App 写入缓存后触发 `WidgetCenter.reloadAllTimelines()`
- **验收**App 调用后Widget 在合理时间内刷新显示新内容(允许系统延迟)
---
## 3. 客户端:写入共享配置/画像/每日缓存App ↔ Widget 共通)
- [ ] **写入共享配置 `widget.config.v1`(包含 `apiBaseUrl`**
- **状态**:未开始
- **要求**
- App 启动后(或 baseURL 计算完成后)写入
- JSON 字段最少包含:`schema_version``apiBaseUrl``saved_at`
- **验收**Widget 侧可读到正确 baseURLdev/pro 随 App 切换)
- [ ] **写入共享画像 `widget.userProfile.v1_2`Onboarding 完成后)**
- **状态**:未开始
- **依赖**:客户端已能获取/持久化用户画像(本地存储已有 `user.profileScoring`
- **要求**
- 结构以现有 `UserProfileV1_2` 输出为准
- JSON 字段建议包含:`schema_version``saved_at``user_profile`
- **验收**Widget 侧可读到 `user_profile`,且可用于请求后端
- [ ] **实现 App 前台“可选主动拉取”并写入 `widget.dailyReco.v1`**
- **状态**:未开始
- **要求**
- 判断 `day_key`(用户时区 `YYYY-MM-DD`)不是今天时才拉取
- 调用 `fetchRecoWidget(k=1)`,成功写入缓存
- 失败不影响 UI不阻塞主流程
- **验收**App 打开后能把今日推荐写入共享缓存Widget reload 后展示一致
---
## 4. iOS Widget读取共享缓存并展示先不接网络也能跑通
- [ ] **Widget 读取 `widget.dailyReco.v1` 并展示**
- **状态**:未开始
- **要求**
- JSON 解析失败容错:视为无缓存
- 展示优先级:今日缓存 → 最近缓存 → 兜底文案
- **验收**手动写入共享缓存后Widget 立即能展示对应文案(或在下一次 reload 展示)
---
## 5. iOS Widget网络拉取每日推荐 + Timeline 每日刷新(核心)
- [ ] **Widget 在缓存过期时请求 `POST /v1/reco/widget` 并写回缓存**
- **状态**:未开始
- **依赖**:可读到 `widget.config.v1`apiBaseUrl`widget.userProfile.v1_2`user_profile
- **要求**
- 无鉴权
- 超时建议 812s
- `Accept-Language` 与 App 保持一致(`en/tc`
- `items=[]` 视为失败,走回退
- **验收**清空今日缓存后Widget 能在一次 timeline 请求内拉取并展示今日推荐(允许先兜底再更新)
- [ ] **实现每日刷新调度TimelinePolicy**
- **状态**:未开始
- **要求**
- `nextRefreshDate` 设置为“下一天本地时间 00:1001:00 随机”之一
- 不追求严格准点,但确保每日范围内能更新
- **验收**:通过修改系统日期/模拟 day_key 变化,可观察到会进入“过期→重新拉取”逻辑
---
## 6. 联调与验收验证(必须)
- [ ] **联调:添加小组件后,未打开 App 也能每日更新(尽力而为)**
- **状态**:未开始
- **步骤**
- 安装带 Widget 的开发包
- 在桌面添加 Widget
- 清空今日缓存并等待 Widget 刷新(或手动触发 reload
- **验收**Widget 能从后端拉取并展示;隔天能更新到新内容(允许系统延迟)
- [ ] **回退验证:断网/后端不可用/返回异常**
- **状态**:未开始
- **验收**
- Widget 不崩溃
- 展示最近缓存或兜底文案
- [ ] **一致性验证App 与 Widget 当天展示一致**
- **状态**:未开始
- **验收**App 主动拉取并写入缓存后Widget reload 后展示同一条 `content_id/text`
---
## 7. 文档与总览更新
- [ ] **补充实现说明(可放入客户端 README 或本需求 overflow**
- **状态**:未开始
- **要求**:记录 suiteName、共享 key、如何本地验证
- [ ] **更新 `spec_kit/overview.md`**
- **状态**:未开始
- **要求**在“Spec Kit Overview”中新增本需求摘要目标/产物/已完成编码变更)

View File

@@ -0,0 +1,122 @@
# Policy Links技术计划
## 1. 目标与原则
**目标**:让客户端所有「隐私协议 / 使用协议」入口(至少包含:首次同意页 + 个人主页弹窗)统一通过后端接口获取链接,并支持按设备语言分发,缺省回退 EN。
**原则**
- 客户端不写死协议 URL避免发版更新
- 请求失败不阻塞主流程(尤其是首次同意页)
- 语言选择策略一致、可解释、可排查(返回 `resolvedLang`
## 2. 接口与契约
### 2.1 HTTP 接口
- **方法与路径**`GET /v1/legal/links`
- **请求头**
- `Accept-Language`: `en` / `tc`(当前仅支持 EN / TC客户端可按 i18n/设备语言映射为其中之一)
- **响应**JSON
- `privacyPolicyUrl: string`
- `termsOfUseUrl: string`
- `resolvedLang: "en" | "tc"`
### 2.2 语言回退(服务端决策)
-`Accept-Language` 缺失/无法解析:回退 `en`
- 若目标语言的链接未配置:回退 `en`
- `resolvedLang` 表示服务端最终命中的语言(用于排查)
## 3. 后端实现方案FastAPI
### 3.1 配置来源
采用环境变量Pydantic Settings承载各语言链接配置
- 推荐配置EN
- `LEGAL_PRIVACY_URL_EN`
- `LEGAL_TERMS_URL_EN`
- 可选:
- `LEGAL_PRIVACY_URL_TC` / `LEGAL_TERMS_URL_TC`
> 补充:若未配置 `LEGAL_*`,后端将使用**内置协议内容页**作为兜底,保证客户端点击后“必有内容”。
### 3.2 路由与业务逻辑
- 新增 `v1/legal` 路由模块
- 读取 `Accept-Language` 做轻量解析(只关心 en/tc其他回退 en
- 根据解析结果选择 URL如缺失则回退到 EN URL
### 3.3 内置协议内容页(兜底,保证有内容)
- **页面接口**
- `GET /v1/legal/privacy`隐私协议HTML
- `GET /v1/legal/terms`使用协议HTML
- **内容来源**
- 直接使用仓库文档:`设计说明文档/隐私协议.md``设计说明文档/用户使用协议.md`
- **语言策略**
- 仍按 `Accept-Language` 解析 `en/tc`;若文档缺少 tc 段落则回退 en
- **链接下发回退**
-`LEGAL_*` 未配置(或显式走内置)时,`GET /v1/legal/links` 返回当前服务域名下的上述内置页面绝对 URL
### 3.4 兼容性与安全
- 要求链接为 HTTPS推荐返回前做基本 URL 校验(通过响应模型约束)
- 不引入数据库变更
### 3.5 后端测试(建议)
- 单测覆盖:
- `Accept-Language` 缺失 → `resolvedLang=en`
- `tc` 命中/缺失回退
- `zh-hant/zh-hk/zh-tw` 等输入能解析为 `tc`
## 4. 客户端实现方案React Native + Expo
### 4.1 统一 service
- 新增 `src/services/legalApi.ts`
- 统一拼接 `API_BASE_URL + /v1/legal/links`
- 统一设置 `Accept-Language`
- 统一超时与错误处理(抛错由调用方兜底)
### 4.2 接入点
- **首次同意页**`app/(splash)/splash.tsx`
- 页面展示时拉取链接
- 拉取失败:不崩溃、不阻塞「同意并继续」
- 点击协议入口:使用 `expo-web-browser` 打开下发链接
- **个人主页弹窗**`components/home/ProfileModal.tsx`
- 弹窗打开时拉取链接
- 点击「隐私协议/使用协议」时打开下发链接(不再 `toastTodo`
### 4.3 语言映射策略(客户端)
- 将客户端当前语言映射到 `en / tc` 二选一:
- 任意 `zh*``tc`
- 其他语言 → `en`
## 5. 可观测与排障
- 开发环境打印:
- 请求地址、请求头(含 `Accept-Language`
- 拉取失败原因(仅 dev
- 线上环境:
- 不打印用户相关敏感信息(本接口仅链接配置,风险较低,但仍应克制日志)
## 6. 发布与回滚策略
- **灰度**dev 环境可先不配 `LEGAL_*`,直接验证内置页面能打开;再逐步切到外部托管链接
- **补齐多语言**:逐步补齐 `zh-CN` / `zh-TW` 链接配置
- **回滚**
- 后端:回滚到仅 EN 配置仍可运行(客户端自动回退)
- 客户端:即使接口失败也不阻塞主流程(保持可用)
## 7. 验收映射(对应 spec
- 首次同意页 + 个人主页弹窗均从 API 获取链接
- 设备语言为 EN/TC 时分别打开对应链接
- 不支持语言回退 EN
- 后端不可达时 App 不崩溃,且同意流程可继续

View File

@@ -0,0 +1,127 @@
# Policy Links高层规范
## 1. 背景与目标
当前客户端首次进入的同意页Splash Consent需要在底部提供「隐私协议」「用户使用协议」的查看入口。为避免协议链接写死在客户端、并支持多语言分发本需求将**协议链接放到后端统一管理与下发**,客户端按设备语言展示对应链接。
### 目标
- **首进页补充协议链接**:在客户端首次进入的同意页底部展示「隐私协议」「用户使用协议」链接入口
- **后端托管与可配置**:协议链接由后端配置与下发,支持后续更新而无需发版
- **按设备语言下发**:后端根据客户端设备语言返回对应语言的链接
- **默认 EN**:当语言缺失/不支持/无法识别时回退为英文EN链接
### 非目标(本阶段不做)
- 不做复杂的协议版本强制重同意机制(例如协议更新后要求重新同意)
- 不规定协议内容必须以何种格式Markdown/HTML/PDF存储本需求仅要求“链接由后端提供并可访问”
## 2. 需求范围与交付物
### 范围
- 客户端:
- 首次进入同意页底部展示 2 个协议链接入口,并在点击时打开对应链接
- **个人主页弹窗ProfileModal**中「隐私协议」「用户使用协议」入口也必须使用同一套后端下发链接(不再写死/不再 TODO
- 后端:提供可被客户端请求的协议链接下发能力,并支持按语言返回不同链接
### 交付物
- 后端:新增/扩展一个“协议链接配置与下发”的接口(或纳入现有配置接口),并可按语言返回
- 客户端:同意页 UI 补充协议链接入口,且链接来源改为后端下发(含兜底策略)
- 文档:本 spec
## 3. 用户体验与交互(高层)
### 3.1 展示位置
- 位置:客户端**首次进入同意页**底部,「同意并继续」按钮下方(或同等位置)展示:
- **隐私协议**
- **用户使用协议**
### 3.2 点击行为
- 点击后打开对应链接内容:
- 推荐使用 App 内 WebView不离开 App也允许外部浏览器打开实现阶段在客户端方案中确定
- 若链接无法获取或打开失败:
- 仍允许用户继续使用“同意并继续”(不阻塞主流程)
- 入口可隐藏或展示为不可点击状态(实现阶段确定,但需保持体验一致且不崩溃)
## 4. 语言策略
### 4.1 语言来源
- 客户端以**设备语言/应用当前语言**作为请求依据(优先级由客户端既有 i18n 策略决定)
- 客户端请求应携带语言信息(例如 `Accept-Language` 头或显式参数)
### 4.2 默认与回退
- 默认语言:**EN**
- 回退策略(必须满足):
- 语言缺失/空值 → EN
- 语言不支持 → EN
- 后端配置缺失对应语言 → EN
### 4.3 支持语言(建议)
当前多语言仅支持 **EN / TC**,建议最少覆盖:
- `en`
- `tc`
> 说明:当前协议文档可能包含英文与繁体版本;当 tc 版本缺失时,系统会回退到 EN。
## 5. 后端职责(高层)
### 5.1 配置管理
- 后端存储并维护协议链接配置,至少包含:
- `privacy_policy_url`
- `terms_of_use_url`
- 配置需支持按语言维度区分(例如每个语言各一套 URL
- **兜底要求**:当外部托管链接未配置或不可用时,后端应提供可访问的内置协议页面(保证客户端点击“必有内容”)
### 5.2 下发接口(建议形态)
- 提供一个可被客户端调用的接口,用于获取协议链接(示例,仅表达形态,不强制路径):
- `GET /v1/legal/links`
- 输入(建议):
- 语言:通过 `Accept-Language``lang` 参数(例如 `en`/`tc`
- 可选平台、App 版本(用于灰度/差异化配置)
- 输出(建议):
- `privacyPolicyUrl: string`
- `termsOfUseUrl: string`
- `resolvedLang: string`(后端最终命中的语言,便于排查)
### 5.3 可用性与安全
- 链接必须可被公网访问(或在 App 可访问的网络环境可达)
- 推荐使用 HTTPS
- 若后端短时不可用,不应导致客户端首进流程崩溃
## 6. 客户端职责(高层)
- 在首次进入同意页渲染时,向后端请求协议链接
- 根据返回结果渲染「隐私协议」「用户使用协议」入口
- 若请求失败或返回缺失:
- 按本 spec 的默认回退策略处理(最终保证 EN
- 不阻塞“同意并继续”主流程
- (建议)对获取到的链接做轻量校验(非空字符串、看起来是 URL避免渲染异常
## 7. 验收标准
- **首进展示**:首次进入同意页时,底部出现「隐私协议」「用户使用协议」入口
- **语言分发**
- 设备语言为 EN 时,打开英文链接
- 设备语言为已支持的其他语言(如 `zh-CN`/`zh-TW`)时,打开对应语言链接
- 设备语言为不支持语言时,默认回退打开 EN 链接
- **稳定性**:后端不可达/返回异常时:
- App 不崩溃
- “同意并继续”仍可正常进入后续流程
## 8. 风险与注意事项
- **法务一致性**:各语言协议内容差异需要法务确认;若部分语言缺失,需明确 `zh-CN` 的回退策略(到 EN 还是 `zh-TW`
- **链接长期可用**:后端配置的 URL 需保证稳定与可访问性(避免过期链接导致合规风险)
- **缓存与更新**:若客户端做本地缓存,需要确保更新后能及时刷新(本期不强制,后续可扩展)

View File

@@ -0,0 +1,68 @@
# Policy Links任务清单
> 说明:本清单按 `plan.md` 拆分为可执行任务。
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
## 0. 前置检查
- [x] 确认客户端存在至少两处协议入口首次同意页、个人主页弹窗ProfileModal
- [x] 确认后端路由前缀体系为 `/v1/*`
## 1. 后端FastAPI——协议链接下发
### 1.1 配置项(环境变量)
- [x] 在后端配置类中新增协议链接配置EN 兜底 + 可选 TC
- [x]`server/env.example` 补充 `LEGAL_*` 配置示例与注释
### 1.2 API 路由与返回结构
- [x] 新增 `GET /v1/legal/links` 接口
- [x] 解析 `Accept-Language`(轻量:只关心 `en/tc`,其他回退 `en`
- [x] 支持语言缺失/未配置时回退 EN
- [x] 返回 `resolvedLang` 便于排查
- [x]`app/main.py` 注册 legal router
### 1.3 代码质量与最小校验
- [x] 确保后端代码可被 `python3 -m compileall` 编译通过
- [x] 可选补充单测覆盖缺省、tc、zh-hant/zh-hk/zh-tw 等输入解析与回退unittest
### 1.4 内置协议内容页(兜底,保证有内容)
- [x] 新增 `GET /v1/legal/privacy``GET /v1/legal/terms` 两个 HTML 页面接口
- [x] 未配置 `LEGAL_*` 时,`GET /v1/legal/links` 自动回退到内置页面绝对 URL
- [x] 更新后端配置默认值:`LEGAL_*` 未配置时使用内置页面(`__internal__` 哨兵)
## 2. 客户端React Native + Expo——统一获取并打开链接
### 2.1 新增统一请求封装
- [x] 新增 `client/src/services/legalApi.ts`
- [x] 统一设置 `Accept-Language`(将客户端当前语言映射到 `en/tc`
- [x] 统一超时与错误处理(抛错给调用方兜底)
### 2.2 首次同意页接入
- [x]`client/app/(splash)/splash.tsx` 的写死 URL 改为调用 `fetchLegalLinks()`
- [x] 拉取失败不崩溃、不阻塞「同意并继续」
- [x] 点击协议入口用 `expo-web-browser` 打开后端下发链接
### 2.3 个人主页弹窗接入
- [x]`client/components/home/ProfileModal.tsx``toastTodo` 替换为打开后端下发链接
- [x] 弹窗打开时拉取链接(失败不影响其他功能)
- [x] 修复 TypeScript 定时器类型RN 环境兼容)
### 2.4 客户端最小验证
- [x] 运行现有 `vitest` 测试通过(`npm test`
- [x] (可选)补充一个轻量测试:`buildAcceptLanguage()` 映射规则en/tc
## 3. 文档与追踪
- [x] `spec_kit/Policy Links/spec.md` 增补:个人主页弹窗也必须使用同一 API接口路径统一为 `GET /v1/legal/links`
- [x] 生成 `spec_kit/Policy Links/plan.md`
- [x] 生成 `spec_kit/Policy Links/tasks.md`
- [x]`spec_kit/overview.md``Policy Links` 条目下标记“任务清单已执行完毕(已完成编码)”

View File

@@ -28,6 +28,27 @@
- **阶段产物**
- `spec_kit/Client User Identity/spec.md`
## App Push
- **目标**:实现“每日提醒”推送闭环:首次进入 Onboarding 可选择每日推送次数05可跳过个人主页每日提醒弹窗可改次数/关闭;客户端使用首次生成 UUID`client_user_id`)与后端关联;后端按用户配置定时推送
- **关键选型**:客户端继续使用 `expo-notifications`;后端使用 Expo Push Service + 定时任务Celery/等价方案)按用户偏好发送
- **阶段产物**
- `spec_kit/App Push/spec.md`
- `spec_kit/App Push/plan.md`
- `spec_kit/App Push/tasks.md`
- **已完成编码(阶段性)**
- 客户端:新增 `client_user_id`UUID v4生成与持久化每日提醒次数范围修正为 **05**0 表示关闭)
- 客户端Onboarding 结束页(每日提醒)在用户选择次数 > 0 时**直接触发系统权限申请**;授权后获取 Expo Push Token 并调用后端 `register/preferences`(移除单独的 push 引导页)
- 客户端:个人主页“每日提醒”弹窗移除测试模式强制无权限逻辑,改为真实读取系统权限;并在开关/点击 OK 时同步后端偏好
- 客户端:新增推送接口封装 `client/src/services/pushApi.ts`token 获取、register/preferences/get、自动上报时区与 locale并携带用户画像供后端 Push 模板使用)
- 后端:新增 Push 数据模型 + Alembic 迁移(`push_tokens` / `push_preferences` / `push_send_log`
- 后端:新增 Push API`/v1/push/register``/v1/push/preferences``/v1/push/test`dev并新增基础限流
- 后端:实现 Celery 定时任务
- 每日生成计划(按用户时区在 9:0024:00 均匀分段随机抖动生成 05 个时间点)
- ETA 发送任务(幂等:同一用户同一天同一 slot 只发一次;用户中途关闭/降次数会跳过)
- 发送文案复用推荐模块 `scene="push"`(降风险)
- 后端:`pytest` 全量通过27 passed
## Project Bootstrap
- **目标**:完成项目仓库初始化与工程约定落地,明确 client/server 结构、dev/pro 环境隔离、MySQL/Redis 资源命名与访问边界
@@ -46,8 +67,8 @@
## Onboarding App Shell
- **目标**落地首次进入体验Onboarding 35 页可跳过 + Push 设置可跳过)与主应用壳(点赞/讨厌、收藏夹入口、通用设置入口)
- **核心范围**Onboarding 多页流程、Push 引导页、主界面反应操作Like/Dislike、收藏夹、设置页版本、iOS 小组件入口/说明)
- **目标**落地首次进入体验Onboarding 35 页可跳过 + 每日提醒可跳过/可关闭)与主应用壳(点赞/讨厌、收藏夹入口、通用设置入口)
- **核心范围**Onboarding 多页流程、主界面反应操作Like/Dislike、收藏夹、设置页版本、iOS 小组件入口/说明)
- **阶段产物**
- `spec_kit/Onboarding App Shell/spec.md`
- `spec_kit/Onboarding App Shell/plan.md`
@@ -66,6 +87,7 @@
- 修复 iOS 签名 Team 配置不一致:为主 AppDebug与 Widget ExtensionDebug/Release显式补齐 `DEVELOPMENT_TEAM=WS92GPX9H2`,避免打包/上传过程中回退到默认 Team 导致显示 “Other Team”
- iOS 构建号已提升到 `2`,并将 `client/ios/client/Info.plist` 改为自动跟随 `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`
- 推送 entitlements 的 `aps-environment` 已切到 `production`(用于 TestFlight/线上包)
- 清理未接入编译的 WidgetKit 骨架残留:移除磁盘上的 `client/ios/MindfulnessWidget/` 文件,并从 `client/ios/client.xcodeproj/project.pbxproj` 删除对应工程引用(避免 Xcode 显示幽灵文件)
## Splash Consent
@@ -76,6 +98,38 @@
- `spec_kit/Splash Consent/plan.md`
- `spec_kit/Splash Consent/tasks.md`
## Policy Links
- **目标**:在客户端首次进入同意页底部补充「隐私协议/用户使用协议」入口,并将协议链接改为**由后端托管与按设备语言下发**(默认回退 EN
- **核心范围**:后端提供协议链接下发接口(可配置、可多语言);客户端按设备语言请求并展示链接,失败不阻塞“同意并继续”
- **阶段产物**
- `spec_kit/Policy Links/spec.md`
- `spec_kit/Policy Links/plan.md`
- `spec_kit/Policy Links/tasks.md`
- **已完成编码(任务清单已执行完毕)**
- 后端新增 `GET /v1/legal/links`(按 `Accept-Language` 分发,默认回退 EN返回 `resolvedLang`
- 后端配置:`LEGAL_*` 环境变量(`server/env.example` 已补齐示例;不配置时走内置协议页兜底)
- 后端新增内置协议内容页:
- `GET /v1/legal/privacy`
- `GET /v1/legal/terms`
- 客户端新增协议接口封装 `client/src/services/legalApi.ts`
- 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入
- 客户端接入两处入口:`app/(splash)/splash.tsx``components/home/ProfileModal.tsx`
- **变更文件清单**
- `server/app/api/v1/legal.py`
- `server/app/core/config.py`
- `server/app/legal_docs.py`
- `server/app/main.py`
- `server/env.example`
- `server/tests/test_legal_links_unittest.py`
- `client/src/services/legalApi.ts`
- `client/src/services/__tests__/legalApi.test.ts`
- `client/app/(splash)/splash.tsx`
- `client/components/home/ProfileModal.tsx`
- `spec_kit/Policy Links/spec.md`
- `spec_kit/Policy Links/plan.md`
- `spec_kit/Policy Links/tasks.md`
## Card UI
- **目标**:优化卡片页 UI 与交互,支持右上角主题切换与个人主页弹窗,并将喜欢/讨厌改为 icon + 动效