fix:APP-push点击文案显示home页
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
|
"requireFullScreen": true,
|
||||||
"bundleIdentifier": "com.damer.mindfulness"
|
"bundleIdentifier": "com.damer.mindfulness"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
PanResponder,
|
PanResponder,
|
||||||
|
AppState,
|
||||||
Animated as RNAnimated,
|
Animated as RNAnimated,
|
||||||
ImageBackground,
|
ImageBackground,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -29,6 +30,8 @@ import {
|
|||||||
getUserProfile,
|
getUserProfile,
|
||||||
setReaction,
|
setReaction,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
|
clearPendingHomePushMessage,
|
||||||
|
getPendingHomePushMessage,
|
||||||
getRecoFeedCache,
|
getRecoFeedCache,
|
||||||
setRecoFeedCache,
|
setRecoFeedCache,
|
||||||
getUserProfileScoring,
|
getUserProfileScoring,
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
} from '@/src/storage/appStorage';
|
} from '@/src/storage/appStorage';
|
||||||
|
|
||||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
|
import { subscribeHomePushMessage } from '@/src/services/pushNotificationRoute';
|
||||||
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||||
|
|
||||||
import ProfileModal from '@/components/home/ProfileModal';
|
import ProfileModal from '@/components/home/ProfileModal';
|
||||||
@@ -109,6 +113,7 @@ export default function HomeScreen() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [likeFilled, setLikeFilled] = useState(false);
|
const [likeFilled, setLikeFilled] = useState(false);
|
||||||
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||||||
|
const [pendingPushItem, setPendingPushItem] = useState<FeedItem | null>(null);
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||||||
const [wrappedText, setWrappedText] = useState<string>('');
|
const [wrappedText, setWrappedText] = useState<string>('');
|
||||||
@@ -119,6 +124,24 @@ export default function HomeScreen() {
|
|||||||
const likedIdsRef = useRef<Set<string>>(new Set());
|
const likedIdsRef = useRef<Set<string>>(new Set());
|
||||||
const likeInFlightRef = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
busyRef.current = busy;
|
busyRef.current = busy;
|
||||||
}, [busy]);
|
}, [busy]);
|
||||||
@@ -187,14 +210,25 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
// 统一文案对象结构
|
// 统一文案对象结构
|
||||||
const currentFeed = useMemo(() => {
|
const currentFeed = useMemo(() => {
|
||||||
if (feedItems.length > 0) {
|
const baseFeed =
|
||||||
return feedItems;
|
feedItems.length > 0
|
||||||
}
|
? feedItems
|
||||||
return MOCK_CONTENT.map(item => ({
|
: MOCK_CONTENT.map(item => ({
|
||||||
content_id: item.id,
|
content_id: item.id,
|
||||||
text: t(item.textKey)
|
text: t(item.textKey)
|
||||||
}));
|
}));
|
||||||
}, [feedItems, t]);
|
|
||||||
|
if (!pendingPushItem) {
|
||||||
|
return baseFeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
pendingPushItem,
|
||||||
|
...baseFeed.filter((entry) => (
|
||||||
|
String(entry.content_id) !== String(pendingPushItem.content_id) && entry.text !== pendingPushItem.text
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}, [feedItems, pendingPushItem, t]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentFeedRef.current = currentFeed;
|
currentFeedRef.current = currentFeed;
|
||||||
}, [currentFeed]);
|
}, [currentFeed]);
|
||||||
@@ -381,13 +415,20 @@ export default function HomeScreen() {
|
|||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const mode = await getThemeMode();
|
const [mode, profile, cache, pendingMessage] = await Promise.all([
|
||||||
const profile = await getUserProfile();
|
getThemeMode(),
|
||||||
const cache = await getRecoFeedCache();
|
getUserProfile(),
|
||||||
|
getRecoFeedCache(),
|
||||||
|
getPendingHomePushMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setThemeModeState(mode);
|
setThemeModeState(mode);
|
||||||
setProfileName(profile.name);
|
setProfileName(profile.name);
|
||||||
|
if (pendingMessage?.text) {
|
||||||
|
await applyPendingPushItem(pendingMessage);
|
||||||
|
if (cancelled) return;
|
||||||
|
}
|
||||||
|
|
||||||
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
|
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
|
||||||
if (mode === 'suixin') {
|
if (mode === 'suixin') {
|
||||||
@@ -415,9 +456,24 @@ export default function HomeScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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(() => {
|
const backgroundColor = useMemo(() => {
|
||||||
if (themeMode === 'suixin') {
|
if (themeMode === 'suixin') {
|
||||||
return suixinBgColor;
|
return suixinBgColor;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||||
import { useFonts } from 'expo-font';
|
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 SplashScreen from 'expo-splash-screen';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { useColorScheme } from '@/components/useColorScheme';
|
||||||
import { initI18n } from '@/src/i18n';
|
import { initI18n } from '@/src/i18n';
|
||||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
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';
|
import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi';
|
||||||
|
|
||||||
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
||||||
@@ -149,6 +150,7 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
function RootLayoutNav() {
|
function RootLayoutNav() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
|
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
|
||||||
@@ -165,6 +167,44 @@ function RootLayoutNav() {
|
|||||||
return () => sub.remove();
|
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 (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIRequiresFullScreen</key>
|
<key>UIRequiresFullScreen</key>
|
||||||
<false/>
|
<true/>
|
||||||
<key>UIStatusBarHidden</key>
|
<key>UIStatusBarHidden</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>UIStatusBarStyle</key>
|
<key>UIStatusBarStyle</key>
|
||||||
|
|||||||
77
client/src/services/__tests__/pushNotificationRoute.test.ts
Normal file
77
client/src/services/__tests__/pushNotificationRoute.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
100
client/src/services/pushNotificationRoute.ts
Normal file
100
client/src/services/pushNotificationRoute.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
getLastHandledNotificationId,
|
||||||
|
setLastHandledNotificationId,
|
||||||
|
setPendingHomePushMessage,
|
||||||
|
type PendingHomePushMessage,
|
||||||
|
} from '../storage/appStorage';
|
||||||
|
|
||||||
|
type NotificationContentLike = {
|
||||||
|
title?: string | null;
|
||||||
|
body?: string | null;
|
||||||
|
data?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationResponseLike = {
|
||||||
|
notification?: {
|
||||||
|
request?: {
|
||||||
|
identifier?: string;
|
||||||
|
content?: NotificationContentLike;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type HomePushListener = (message: PendingHomePushMessage) => void;
|
||||||
|
|
||||||
|
const homePushListeners = new Set<HomePushListener>();
|
||||||
|
|
||||||
|
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<PendingHomePushMessage | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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_TOKEN = 'push.lastRegisteredToken'; // 旧:仅 token(保留兼容读取)
|
||||||
const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; // 旧:时间(保留兼容)
|
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_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 PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
||||||
export type Reaction = 'like' | 'dislike';
|
export type Reaction = 'like' | 'dislike';
|
||||||
@@ -112,6 +114,52 @@ export async function setLastRegisteredPushPayload(payload: Omit<LastRegisteredP
|
|||||||
await setLastRegisteredPushToken(payload.pushToken);
|
await setLastRegisteredPushToken(payload.pushToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PendingHomePushMessage = {
|
||||||
|
notification_id: string;
|
||||||
|
received_at: string; // ISO8601
|
||||||
|
text: string;
|
||||||
|
title?: string;
|
||||||
|
content_id?: number;
|
||||||
|
scene?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPendingHomePushMessage(): Promise<PendingHomePushMessage | null> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY_PUSH_PENDING_HOME_MESSAGE);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw) as Partial<PendingHomePushMessage>;
|
||||||
|
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<void> {
|
||||||
|
await AsyncStorage.setItem(KEY_PUSH_PENDING_HOME_MESSAGE, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPendingHomePushMessage(): Promise<void> {
|
||||||
|
await AsyncStorage.removeItem(KEY_PUSH_PENDING_HOME_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLastHandledNotificationId(): Promise<string | null> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID);
|
||||||
|
return raw ? String(raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLastHandledNotificationId(notificationId: string): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID, String(notificationId));
|
||||||
|
}
|
||||||
|
|
||||||
export type RecoFeedCacheItem = {
|
export type RecoFeedCacheItem = {
|
||||||
content_id: number;
|
content_id: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
@@ -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_token import PushToken
|
||||||
from app.db.models.push_send_log import PushSendLog
|
from app.db.models.push_send_log import PushSendLog
|
||||||
from app.db.session import get_db
|
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.features.user_profile_scoring.types import UserProfileV1_2
|
||||||
from app.worker import celery_app
|
from app.worker import celery_app
|
||||||
|
|
||||||
@@ -289,7 +290,16 @@ async def test_push(
|
|||||||
title = req.title or "Dear Mama"
|
title = req.title or "Dear Mama"
|
||||||
body = req.body or "这是一条测试推送(dev)。"
|
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
|
_ = accept_language
|
||||||
return {"status": "ok", "expo": expo_res}
|
return {"status": "ok", "expo": expo_res}
|
||||||
|
|
||||||
|
|||||||
26
server/app/features/push_payload.py
Normal file
26
server/app/features/push_payload.py
Normal file
@@ -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
|
||||||
@@ -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_send_log import PushSendLog
|
||||||
from app.db.models.push_token import PushToken
|
from app.db.models.push_token import PushToken
|
||||||
from app.db.session import AsyncSessionLocal
|
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.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.scoring import build_user_profile_from_questionnaire
|
||||||
from app.features.user_profile_scoring.types import QuestionnaireAnswersV1_2, UserProfileV1_2
|
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),
|
to=str(token.push_token),
|
||||||
title=title,
|
title=title,
|
||||||
body=body,
|
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
|
# 6) 解析 Expo 回执,必要时停用 token
|
||||||
|
|||||||
32
server/tests/test_push_payload.py
Normal file
32
server/tests/test_push_payload.py
Normal file
@@ -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"] == "先看到这句,再回到首页。"
|
||||||
@@ -49,6 +49,8 @@
|
|||||||
- ETA 发送任务(幂等:同一用户同一天同一 slot 只发一次;用户中途关闭/降次数会跳过)
|
- ETA 发送任务(幂等:同一用户同一天同一 slot 只发一次;用户中途关闭/降次数会跳过)
|
||||||
- 发送文案复用推荐模块 `scene="push"`(降风险)
|
- 发送文案复用推荐模块 `scene="push"`(降风险)
|
||||||
- 后端:`pytest` 全量通过(27 passed)
|
- 后端:`pytest` 全量通过(27 passed)
|
||||||
|
- 客户端:新增通知点击消费链路,支持前后台/冷启动点击每日推荐 Push 后,将文案暂存并在进入 `home` 时优先展示
|
||||||
|
- 后端:每日推荐 Push payload 新增 `target_screen/home_text/content_id/deep_link`,保证客户端点击通知后可恢复首页展示上下文
|
||||||
|
|
||||||
## Project Bootstrap
|
## Project Bootstrap
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user