diff --git a/client/app.json b/client/app.json index c39bae6..3691e13 100644 --- a/client/app.json +++ b/client/app.json @@ -15,6 +15,7 @@ }, "ios": { "supportsTablet": true, + "requireFullScreen": true, "bundleIdentifier": "com.damer.mindfulness" }, "android": { diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index ddefc23..d4031d4 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -5,6 +5,7 @@ import { Text, Pressable, PanResponder, + AppState, Animated as RNAnimated, ImageBackground, Platform, @@ -29,6 +30,8 @@ import { getUserProfile, setReaction, setThemeMode, + clearPendingHomePushMessage, + getPendingHomePushMessage, getRecoFeedCache, setRecoFeedCache, getUserProfileScoring, @@ -41,6 +44,7 @@ import { } from '@/src/storage/appStorage'; import { fetchRecoFeed } from '@/src/services/recoApi'; +import { subscribeHomePushMessage } from '@/src/services/pushNotificationRoute'; import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale'; import ProfileModal from '@/components/home/ProfileModal'; @@ -109,6 +113,7 @@ export default function HomeScreen() { const [busy, setBusy] = useState(false); const [likeFilled, setLikeFilled] = useState(false); const [feedItems, setFeedItems] = useState([]); + const [pendingPushItem, setPendingPushItem] = useState(null); const [isFetching, setIsFetching] = useState(false); const [cardWidth, setCardWidth] = useState(null); const [wrappedText, setWrappedText] = useState(''); @@ -119,6 +124,24 @@ export default function HomeScreen() { const likedIdsRef = useRef>(new Set()); const likeInFlightRef = useRef(false); + const applyPendingPushItem = useCallback(async (message: { notification_id: string; content_id?: number; text: string }) => { + const nextItem: FeedItem = { + content_id: message.content_id != null ? String(message.content_id) : `push:${message.notification_id}`, + text: message.text, + }; + setPendingPushItem(nextItem); + indexRef.current = 0; + setIndex(0); + setLikeFilled(likedIdsRef.current.has(String(nextItem.content_id))); + await clearPendingHomePushMessage(); + }, []); + + const consumePendingPushItem = useCallback(async () => { + const pendingMessage = await getPendingHomePushMessage(); + if (!pendingMessage?.text) return; + await applyPendingPushItem(pendingMessage); + }, [applyPendingPushItem]); + useEffect(() => { busyRef.current = busy; }, [busy]); @@ -187,14 +210,25 @@ export default function HomeScreen() { // 统一文案对象结构 const currentFeed = useMemo(() => { - if (feedItems.length > 0) { - return feedItems; + const baseFeed = + feedItems.length > 0 + ? feedItems + : MOCK_CONTENT.map(item => ({ + content_id: item.id, + text: t(item.textKey) + })); + + if (!pendingPushItem) { + return baseFeed; } - return MOCK_CONTENT.map(item => ({ - content_id: item.id, - text: t(item.textKey) - })); - }, [feedItems, t]); + + return [ + pendingPushItem, + ...baseFeed.filter((entry) => ( + String(entry.content_id) !== String(pendingPushItem.content_id) && entry.text !== pendingPushItem.text + )), + ]; + }, [feedItems, pendingPushItem, t]); useEffect(() => { currentFeedRef.current = currentFeed; }, [currentFeed]); @@ -381,13 +415,20 @@ export default function HomeScreen() { useCallback(() => { let cancelled = false; (async () => { - const mode = await getThemeMode(); - const profile = await getUserProfile(); - const cache = await getRecoFeedCache(); + const [mode, profile, cache, pendingMessage] = await Promise.all([ + getThemeMode(), + getUserProfile(), + getRecoFeedCache(), + getPendingHomePushMessage(), + ]); if (cancelled) return; setThemeModeState(mode); setProfileName(profile.name); + if (pendingMessage?.text) { + await applyPendingPushItem(pendingMessage); + if (cancelled) return; + } // 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算) if (mode === 'suixin') { @@ -415,9 +456,24 @@ export default function HomeScreen() { return () => { cancelled = true; }; - }, [fetchNewFeed, recoLang, ensureSuixinReady]) + }, [applyPendingPushItem, fetchNewFeed, recoLang, ensureSuixinReady]) ); + useEffect(() => { + const unsubscribe = subscribeHomePushMessage((message) => { + void applyPendingPushItem(message); + }); + return unsubscribe; + }, [applyPendingPushItem]); + + useEffect(() => { + const sub = AppState.addEventListener('change', (state) => { + if (state !== 'active') return; + void consumePendingPushItem(); + }); + return () => sub.remove(); + }, [consumePendingPushItem]); + const backgroundColor = useMemo(() => { if (themeMode === 'suixin') { return suixinBgColor; diff --git a/client/app/_layout.tsx b/client/app/_layout.tsx index 5da97d7..defd7da 100644 --- a/client/app/_layout.tsx +++ b/client/app/_layout.tsx @@ -1,7 +1,7 @@ import FontAwesome from '@expo/vector-icons/FontAwesome'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { useFonts } from 'expo-font'; -import { Stack } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import * as Notifications from 'expo-notifications'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -11,7 +11,8 @@ import { Animated, AppState, Image, StyleSheet, View } 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'; +import { getConsentAccepted, getOnboardingCompleted, getOrCreateClientUserId } from '@/src/storage/appStorage'; +import { persistHomePushMessageFromResponse } from '@/src/services/pushNotificationRoute'; import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi'; // 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常) @@ -149,6 +150,7 @@ export default function RootLayout() { function RootLayoutNav() { const colorScheme = useColorScheme(); + const router = useRouter(); useEffect(() => { // iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐” @@ -165,6 +167,44 @@ function RootLayoutNav() { return () => sub.remove(); }, []); + const handleNotificationResponse = useCallback( + async (response: Notifications.NotificationResponse) => { + const message = await persistHomePushMessageFromResponse(response); + if (!message) return; + + const [consentAccepted, onboardingCompleted] = await Promise.all([ + getConsentAccepted(), + getOnboardingCompleted(), + ]); + if (consentAccepted && onboardingCompleted) { + router.replace('/(app)/home'); + } + }, + [router] + ); + + useEffect(() => { + let cancelled = false; + + Notifications.getLastNotificationResponseAsync() + .then((response) => { + if (cancelled || !response) return; + return handleNotificationResponse(response); + }) + .catch(() => { + // ignore:通知冷启动读取失败不阻塞主流程 + }); + + const sub = Notifications.addNotificationResponseReceivedListener((response) => { + void handleNotificationResponse(response); + }); + + return () => { + cancelled = true; + sub.remove(); + }; + }, [handleNotificationResponse]); + return ( diff --git a/client/ios/client/Info.plist b/client/ios/client/Info.plist index 9b2806b..f8f63a2 100644 --- a/client/ios/client/Info.plist +++ b/client/ios/client/Info.plist @@ -60,7 +60,7 @@ arm64 UIRequiresFullScreen - + UIStatusBarHidden UIStatusBarStyle diff --git a/client/src/services/__tests__/pushNotificationRoute.test.ts b/client/src/services/__tests__/pushNotificationRoute.test.ts new file mode 100644 index 0000000..be85f6f --- /dev/null +++ b/client/src/services/__tests__/pushNotificationRoute.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../storage/appStorage', () => ({ + getLastHandledNotificationId: vi.fn(async () => null), + setLastHandledNotificationId: vi.fn(async () => undefined), + setPendingHomePushMessage: vi.fn(async () => undefined), +})); + +import { buildPendingHomePushMessageFromResponse } from '../pushNotificationRoute'; + +describe('pushNotificationRoute.buildPendingHomePushMessageFromResponse', () => { + it('能从每日推荐 push payload 提取 home 文案', () => { + const result = buildPendingHomePushMessageFromResponse({ + notification: { + request: { + identifier: 'notif-1', + content: { + title: '每日推荐', + body: '先用通知正文兜底', + data: { + scene: 'push', + target_screen: 'home', + home_text: '点击推送后回到首页展示这句文案', + content_id: '42', + }, + }, + }, + }, + }); + + expect(result).toMatchObject({ + notification_id: 'notif-1', + title: '每日推荐', + text: '点击推送后回到首页展示这句文案', + content_id: 42, + scene: 'push', + }); + }); + + it('home_text 缺失时回退到通知正文', () => { + const result = buildPendingHomePushMessageFromResponse({ + notification: { + request: { + identifier: 'notif-2', + content: { + body: '直接展示通知正文', + data: { + target_screen: 'home', + }, + }, + }, + }, + }); + + expect(result?.text).toBe('直接展示通知正文'); + expect(result?.notification_id).toBe('notif-2'); + }); + + it('非 home 目标且非 push 场景时忽略', () => { + const result = buildPendingHomePushMessageFromResponse({ + notification: { + request: { + identifier: 'notif-3', + content: { + body: '这条不应该进入首页', + data: { + target_screen: 'profile', + scene: 'other', + }, + }, + }, + }, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/client/src/services/pushNotificationRoute.ts b/client/src/services/pushNotificationRoute.ts new file mode 100644 index 0000000..2e948ee --- /dev/null +++ b/client/src/services/pushNotificationRoute.ts @@ -0,0 +1,100 @@ +import { + getLastHandledNotificationId, + setLastHandledNotificationId, + setPendingHomePushMessage, + type PendingHomePushMessage, +} from '../storage/appStorage'; + +type NotificationContentLike = { + title?: string | null; + body?: string | null; + data?: Record | null; +}; + +export type NotificationResponseLike = { + notification?: { + request?: { + identifier?: string; + content?: NotificationContentLike; + }; + }; +}; + +type HomePushListener = (message: PendingHomePushMessage) => void; + +const homePushListeners = new Set(); + +function readString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function readContentId(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return Math.trunc(parsed); + } + return undefined; +} + +function notifyHomePushListeners(message: PendingHomePushMessage): void { + for (const listener of homePushListeners) { + listener(message); + } +} + +export function subscribeHomePushMessage(listener: HomePushListener): () => void { + homePushListeners.add(listener); + return () => { + homePushListeners.delete(listener); + }; +} + +export function buildPendingHomePushMessageFromResponse( + response: NotificationResponseLike +): PendingHomePushMessage | null { + const request = response.notification?.request; + const content = request?.content; + const data = + content?.data && typeof content.data === 'object' && !Array.isArray(content.data) + ? content.data + : {}; + + const targetScreen = readString(data.target_screen); + const scene = readString(data.scene); + const shouldOpenHome = targetScreen === 'home' || scene === 'push'; + if (!shouldOpenHome) return null; + + const text = readString(data.home_text) ?? readString(content?.body); + if (!text) return null; + + return { + notification_id: readString(request?.identifier) ?? `push-${Date.now()}`, + received_at: new Date().toISOString(), + text, + title: readString(content?.title), + content_id: readContentId(data.content_id), + scene, + }; +} + +export async function persistHomePushMessageFromResponse( + response: NotificationResponseLike +): Promise { + const message = buildPendingHomePushMessageFromResponse(response); + if (!message) return null; + + const lastHandledNotificationId = await getLastHandledNotificationId(); + if (lastHandledNotificationId === message.notification_id) { + return null; + } + + await setPendingHomePushMessage(message); + await setLastHandledNotificationId(message.notification_id); + notifyHomePushListeners(message); + return message; +} diff --git a/client/src/storage/appStorage.ts b/client/src/storage/appStorage.ts index ea505da..c1c5fc4 100644 --- a/client/src/storage/appStorage.ts +++ b/client/src/storage/appStorage.ts @@ -21,6 +21,8 @@ const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings'; const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken'; // 旧:仅 token(保留兼容读取) const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; // 旧:时间(保留兼容) const KEY_PUSH_LAST_REGISTERED_PAYLOAD = 'push.lastRegisteredPayload'; // 新:token+client_user_id+env+app_id +const KEY_PUSH_PENDING_HOME_MESSAGE = 'push.pendingHomeMessage'; +const KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID = 'push.lastHandledNotificationId'; export type PushPromptState = 'enabled' | 'skipped' | 'unknown'; export type Reaction = 'like' | 'dislike'; @@ -112,6 +114,52 @@ export async function setLastRegisteredPushPayload(payload: Omit { + const raw = await AsyncStorage.getItem(KEY_PUSH_PENDING_HOME_MESSAGE); + if (!raw) return null; + try { + const obj = JSON.parse(raw) as Partial; + if (!obj || typeof obj !== 'object') return null; + if (!obj.notification_id || !obj.received_at || !obj.text) return null; + return { + notification_id: String(obj.notification_id), + received_at: String(obj.received_at), + text: String(obj.text), + title: obj.title ? String(obj.title) : undefined, + content_id: Number.isFinite(obj.content_id) ? Number(obj.content_id) : undefined, + scene: obj.scene ? String(obj.scene) : undefined, + }; + } catch { + return null; + } +} + +export async function setPendingHomePushMessage(message: PendingHomePushMessage): Promise { + await AsyncStorage.setItem(KEY_PUSH_PENDING_HOME_MESSAGE, JSON.stringify(message)); +} + +export async function clearPendingHomePushMessage(): Promise { + await AsyncStorage.removeItem(KEY_PUSH_PENDING_HOME_MESSAGE); +} + +export async function getLastHandledNotificationId(): Promise { + const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID); + return raw ? String(raw) : null; +} + +export async function setLastHandledNotificationId(notificationId: string): Promise { + await AsyncStorage.setItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID, String(notificationId)); +} + export type RecoFeedCacheItem = { content_id: number; text: string; diff --git a/server/app/api/v1/push.py b/server/app/api/v1/push.py index 3a14821..cc52999 100644 --- a/server/app/api/v1/push.py +++ b/server/app/api/v1/push.py @@ -16,6 +16,7 @@ from app.db.models.push_preference import PushPreference from app.db.models.push_token import PushToken from app.db.models.push_send_log import PushSendLog from app.db.session import get_db +from app.features.push_payload import build_home_push_data from app.features.user_profile_scoring.types import UserProfileV1_2 from app.worker import celery_app @@ -289,7 +290,16 @@ async def test_push( title = req.title or "Dear 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}) + expo_res = await _send_expo_push( + to=token.push_token, + title=title, + body=body, + data=build_home_push_data( + client_user_id=req.client_user_id, + body=body, + scene="push", + ), + ) _ = accept_language return {"status": "ok", "expo": expo_res} diff --git a/server/app/features/push_payload.py b/server/app/features/push_payload.py new file mode 100644 index 0000000..569f7d2 --- /dev/null +++ b/server/app/features/push_payload.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Any, Optional + + +def build_home_push_data( + *, + client_user_id: str, + body: str, + scene: str = "push", + content_id: Optional[int] = None, +) -> dict[str, Any]: + """ + 构建客户端点击通知后回到 Home 所需的最小 payload。 + """ + + data: dict[str, Any] = { + "client_user_id": str(client_user_id), + "scene": str(scene), + "target_screen": "home", + "deep_link": "client://home", + "home_text": str(body), + } + if content_id is not None: + data["content_id"] = int(content_id) + return data diff --git a/server/app/tasks/push.py b/server/app/tasks/push.py index d902caa..962b851 100644 --- a/server/app/tasks/push.py +++ b/server/app/tasks/push.py @@ -16,6 +16,7 @@ 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.push_payload import build_home_push_data 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 @@ -365,7 +366,12 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index: to=str(token.push_token), title=title, body=body, - data={"client_user_id": client_user_id, "scene": "push"}, + data=build_home_push_data( + client_user_id=client_user_id, + body=body, + scene="push", + content_id=picked_content_id, + ), ) # 6) 解析 Expo 回执,必要时停用 token diff --git a/server/tests/test_push_payload.py b/server/tests/test_push_payload.py new file mode 100644 index 0000000..67d4baf --- /dev/null +++ b/server/tests/test_push_payload.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from app.features.push_payload import build_home_push_data + + +def test_build_home_push_data_contains_home_route_fields() -> None: + payload = build_home_push_data( + client_user_id="client-123", + body="今天也请温柔地对自己说话。", + scene="push", + content_id=9, + ) + + assert payload == { + "client_user_id": "client-123", + "scene": "push", + "target_screen": "home", + "deep_link": "client://home", + "home_text": "今天也请温柔地对自己说话。", + "content_id": 9, + } + + +def test_build_home_push_data_omits_content_id_when_missing() -> None: + payload = build_home_push_data( + client_user_id="client-123", + body="先看到这句,再回到首页。", + ) + + assert "content_id" not in payload + assert payload["target_screen"] == "home" + assert payload["home_text"] == "先看到这句,再回到首页。" diff --git a/spec_kit/overview.md b/spec_kit/overview.md index 57f4c59..ee56d66 100644 --- a/spec_kit/overview.md +++ b/spec_kit/overview.md @@ -49,6 +49,8 @@ - ETA 发送任务(幂等:同一用户同一天同一 slot 只发一次;用户中途关闭/降次数会跳过) - 发送文案复用推荐模块 `scene="push"`(降风险) - 后端:`pytest` 全量通过(27 passed) + - 客户端:新增通知点击消费链路,支持前后台/冷启动点击每日推荐 Push 后,将文案暂存并在进入 `home` 时优先展示 + - 后端:每日推荐 Push payload 新增 `target_screen/home_text/content_id/deep_link`,保证客户端点击通知后可恢复首页展示上下文 ## Project Bootstrap