4 Commits

Author SHA1 Message Date
吕新雨
076bd5636f fix:更新APP-PUSH 2026-02-10 17:23:38 +08:00
吕新雨
154f347ddb 注册token排查 2026-02-10 16:57:36 +08:00
吕新雨
dec3ac82e1 fix:后端错误 2026-02-10 16:47:18 +08:00
吕新雨
1fbc0aa3f8 fix:重复点击 2026-02-10 15:06:50 +08:00
13 changed files with 340 additions and 49 deletions

View File

@@ -113,6 +113,18 @@ export default function HomeScreen() {
const [cardWidth, setCardWidth] = useState<number | null>(null);
const [wrappedText, setWrappedText] = useState<string>('');
const wrapLogRef = useRef<{ key: string } | null>(null);
const busyRef = useRef(false);
const indexRef = useRef(0);
const currentFeedRef = useRef<FeedItem[]>([]);
const likedIdsRef = useRef<Set<string>>(new Set());
const likeInFlightRef = useRef(false);
useEffect(() => {
busyRef.current = busy;
}, [busy]);
useEffect(() => {
indexRef.current = index;
}, [index]);
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
@@ -183,6 +195,9 @@ export default function HomeScreen() {
text: t(item.textKey)
}));
}, [feedItems, t]);
useEffect(() => {
currentFeedRef.current = currentFeed;
}, [currentFeed]);
const item = useMemo(() => {
const data = currentFeed[index % currentFeed.length];
@@ -430,24 +445,60 @@ export default function HomeScreen() {
transform: [{ scale: likeScale.value }],
}));
const setBusySafe = useCallback((next: boolean) => {
busyRef.current = next;
setBusy(next);
}, []);
const setLikeInFlight = useCallback((next: boolean) => {
likeInFlightRef.current = next;
}, []);
const syncLikeFilledByIndex = useCallback((nextIndex: number) => {
const list = currentFeedRef.current;
const len = list.length;
if (!len) {
setLikeFilled(false);
return;
}
const safe = ((nextIndex % len) + len) % len;
const nextId = String(list[safe]?.content_id);
setLikeFilled(likedIdsRef.current.has(nextId));
}, []);
const applyIndexChange = useCallback((nextIndex: number) => {
indexRef.current = nextIndex;
setIndex(nextIndex);
syncLikeFilledByIndex(nextIndex);
}, [syncLikeFilledByIndex]);
const maybeFetchNewFeedIfNeeded = useCallback((nextIndex: number) => {
const len = currentFeedRef.current.length;
if (!len) return;
// 当接近当前列表末尾时(例如还剩 5 条)提前拉取
if (nextIndex + 5 >= len && !isFetchingRef.current) {
fetchNewFeed();
}
}, [fetchNewFeed]);
// 切换到下一条文案的统一动画逻辑
const triggerNextContent = useCallback(() => {
if (busy) return;
setBusy(true);
if (busyRef.current) return;
setBusySafe(true);
// 注意:不要在 Reanimated worklet 回调里读取 React ref例如 indexRef/currentFeedRef会导致值不更新或异常
const nextIndex = indexRef.current + 1;
// 1. 当前文案向上移动并消失
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
// 2. 切换数据索引
runOnJS(setIndex)(index + 1);
runOnJS(setLikeFilled)(false);
runOnJS(applyIndexChange)(nextIndex);
runOnJS(advanceSuixinOnNextContent)();
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条
if (index + 5 >= currentFeed.length && !isFetching) {
runOnJS(fetchNewFeed)();
}
// 检查是否需要拉取新文案(注意:不要把匿名函数塞进 runOnJS可能导致原生崩溃
runOnJS(maybeFetchNewFeedIfNeeded)(nextIndex);
// 3. 准备下一条文案:先瞬移到下方 40pt
translateY.value = 40;
@@ -456,26 +507,29 @@ export default function HomeScreen() {
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
if (finished) {
runOnJS(setBusy)(false);
runOnJS(setBusySafe)(false);
}
});
}
});
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
}, [applyIndexChange, setBusySafe, translateY, opacity, advanceSuixinOnNextContent, maybeFetchNewFeedIfNeeded]);
// 切换到上一条文案的统一动画逻辑(下滑触发)
const triggerPrevContent = useCallback(() => {
if (busy) return;
setBusy(true);
if (busyRef.current) return;
setBusySafe(true);
// 注意:同上,不要在 worklet 里读取 React ref
const len = currentFeedRef.current.length;
const raw = indexRef.current - 1;
const nextIndex = len ? ((raw % len) + len) % len : Math.max(0, raw);
// 1. 当前文案向下移动并消失
translateY.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.quad) });
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
// 2. 切换数据索引(循环回退)
const nextIndex = index - 1 < 0 ? Math.max(0, currentFeed.length - 1) : index - 1;
runOnJS(setIndex)(nextIndex);
runOnJS(setLikeFilled)(false);
runOnJS(applyIndexChange)(nextIndex);
// 3. 准备上一条文案:先瞬移到上方 40pt
translateY.value = -40;
@@ -484,12 +538,12 @@ export default function HomeScreen() {
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
if (finished) {
runOnJS(setBusy)(false);
runOnJS(setBusySafe)(false);
}
});
}
});
}, [busy, index, currentFeed.length, translateY, opacity]);
}, [applyIndexChange, setBusySafe, translateY, opacity]);
const lastTapRef = useRef<number>(0);
@@ -533,7 +587,21 @@ export default function HomeScreen() {
).current;
async function onPressLike() {
if (busy) return;
if (busyRef.current) return;
if (likeInFlightRef.current) return;
// 已经喜欢过:不重复写入收藏,直接当作“下一条”
if (likeFilled || likedIdsRef.current.has(item.id)) {
triggerNextContent();
return;
}
likeInFlightRef.current = true;
const likedItemId = item.id;
const likedItemText = item.text;
// 先记下“已喜欢”,保证回退时能恢复点亮状态(即便异步保存稍后才完成)
likedIdsRef.current.add(likedItemId);
setLikeFilled(true);
// 1. 获取当前日期
@@ -543,24 +611,33 @@ export default function HomeScreen() {
// 2. 保存到收藏夹,包含当前背景信息
const favItem = {
favId: String(Date.now()), // 生成唯一 ID
id: item.id,
text: item.text,
id: likedItemId,
text: likedItemText,
date: dateStr,
themeMode: themeMode,
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
};
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
try {
await addFavorite(favItem);
} catch (error) {
console.error('Home: addFavorite 失败', error);
}
// 3. 记录到后端 Reaction喜欢
console.log('Home: Triggering setReaction', item.id);
try {
await setReaction(item.id, 'like');
} catch (error) {
console.error('Home: setReaction 失败', error);
}
// 4. 爱心缩放动画
likeScale.value = withSequence(
withTiming(0.8, { duration: 100 }),
withTiming(1.2, { duration: 150 }),
withTiming(1, { duration: 100 }, (finished) => {
runOnJS(setLikeInFlight)(false);
if (finished) {
console.log('Home: Like animation finished, triggering next content');
runOnJS(triggerNextContent)();

View File

@@ -12,7 +12,7 @@ import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnai
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import { fetchRecoFeed } from '@/src/services/recoApi';
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
import {
recordRecoFeedServed,
setOnboardingCompleted,
@@ -44,6 +44,7 @@ export default function OnboardingScreen() {
const [name, setName] = useState('');
const [selections, setSelections] = useState<Record<string, string[]>>({});
const [reminderTimes, setReminderTimes] = useState(3);
const [finishing, setFinishing] = useState(false);
const currentStep = STEPS[stepIndex];
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
@@ -56,6 +57,9 @@ export default function OnboardingScreen() {
}, [t, currentStep]);
async function onFinish() {
if (finishing) return;
setFinishing(true);
try {
// 用户选择每日次数 > 0在此页直接触发系统通知权限已移除单独的 push 引导页)。
const wantsPush = reminderTimes > 0;
@@ -124,7 +128,8 @@ export default function OnboardingScreen() {
await setPushPromptState('unknown');
try {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
// iOS 可能出现 provisional临时授权也应视为“已授权”
if (status !== 'granted' && status !== ('provisional' as any)) {
await setPushPromptState('skipped');
return;
}
@@ -136,10 +141,8 @@ export default function OnboardingScreen() {
return;
}
// 1) 获取 Expo Push Token失败才认为“推送开启失败”)
const expoPushToken = await getExpoPushTokenOrThrow();
// 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
await registerPushToken({ pushToken: expoPushToken });
// 1) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
await ensurePushTokenRegisteredIfPermitted();
// 3) 上报推送偏好(幂等)
// 注意:这一步失败时,后端仍可能已成功接收 token。
@@ -159,9 +162,17 @@ export default function OnboardingScreen() {
} finally {
router.replace('/(app)/home');
}
} catch (e) {
// 极端情况下(例如本地存储/初始化异常)避免卡死在 loading提示并允许用户重试
const msg = e instanceof Error ? e.message : String(e);
console.warn('[OnboardingFinish] 异常:', msg);
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
setFinishing(false);
}
}
const onNext = () => {
if (finishing) return;
if (stepIndex < STEPS.length - 1) {
setStepIndex(stepIndex + 1);
} else {
@@ -170,6 +181,7 @@ export default function OnboardingScreen() {
};
const onBack = () => {
if (finishing) return;
if (stepIndex > 0) {
setStepIndex(stepIndex - 1);
}
@@ -177,6 +189,7 @@ export default function OnboardingScreen() {
/** 只跳過當前這一步(不填/不選當前題,進入下一步) */
const handleSkipCurrentStep = () => {
if (finishing) return;
if (currentStep.type === 'name') {
onNext();
} else if (currentStep.type === 'selection') {
@@ -200,6 +213,7 @@ export default function OnboardingScreen() {
};
const handleSkipStep = () => {
if (finishing) return;
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
onNext();
};
@@ -236,6 +250,7 @@ export default function OnboardingScreen() {
value={Math.max(1, reminderTimes)}
onChange={setReminderTimes}
onFinish={onFinish}
loading={finishing}
onSkip={() => {
// 跳过每日提醒:视为 0 次(关闭)
setReminderTimes(0);

View File

@@ -12,6 +12,7 @@ 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 { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi';
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
Notifications.setNotificationHandler({
@@ -75,6 +76,28 @@ export default function RootLayout() {
});
}, []);
useEffect(() => {
// 只要系统通知权限已经 granted就主动上报 Push Token不依赖用户在“每日提醒”里点确认
ensurePushTokenRegisteredIfPermitted()
.then((res) => {
if (__DEV__) console.log('[push_token_sync]', res);
})
.catch((e) => {
if (__DEV__) console.warn('[push_token_sync] 失败(不阻塞启动)', e);
});
}, []);
useEffect(() => {
// 兜底:当用户在系统弹窗/系统设置里变更权限后App 回到前台时再同步一次 token
const sub = AppState.addEventListener('change', (state) => {
if (state !== 'active') return;
ensurePushTokenRegisteredIfPermitted().catch(() => {
// ignore不阻塞
});
});
return () => sub.remove();
}, []);
useEffect(() => {
// 字体与 i18n 都准备好后,允许渲染 App原生 splash 的隐藏交给 onLayout避免“硬切/闪白”)
if (loaded && i18nReady) setAppReady(true);
@@ -113,7 +136,7 @@ export default function RootLayout() {
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
<View style={styles.splashOverlay}>
<Image
source={require('../assets/images/splashScreen.png')}
source={require('../assets/images/Screen_page.png')}
style={styles.splashImage}
resizeMode="contain"
/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -41,7 +41,7 @@ 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';
import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
const { width } = Dimensions.get('window');
@@ -430,14 +430,13 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
// 调试:打印状态
console.log('Push Permission Status:', status);
if (status === 'granted') {
if (status === 'granted' || (status as any) === 'provisional') {
setPushEnabled(true);
setHasSystemPermission(true);
// 获取 token 并上报后端(幂等)
try {
const expoPushToken = await getExpoPushTokenOrThrow();
await registerPushToken({ pushToken: expoPushToken });
await ensurePushTokenRegisteredIfPermitted();
// 偏好同步失败不应被用户感知为“开启失败”
// (常见现象:后端已接收 token但偏好接口短暂失败/超时)
try {
@@ -479,6 +478,13 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled };
await setDailyReminderSettings(next);
// 若系统权限已授予且用户意愿为开启:兜底同步一次 token避免“没点开关/没触发 toggle 导致后端无 token”
if (nextEnabled) {
ensurePushTokenRegisteredIfPermitted().catch(() => {
// ignore不阻塞保存
});
}
// 同步后端偏好(幂等;失败不阻塞)
try {
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
import { View, StyleSheet, TouchableOpacity, Text, Platform, ActivityIndicator } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LinearGradient } from 'expo-linear-gradient';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import AddIcon from '@/assets/images/icon/add_icon.svg';
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
@@ -12,9 +13,11 @@ interface ReminderStepProps {
onChange: (value: number) => void;
onFinish: () => void;
onSkip?: () => void;
/** 完成后请求通知权限时的加载态 */
loading?: boolean;
}
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
export function ReminderStep({ value, onChange, onFinish, loading = false }: ReminderStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -30,7 +33,7 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
return (
<View style={styles.container}>
<View style={styles.counterContainer}>
<TouchableOpacity onPress={handleReduce} activeOpacity={0.7}>
<TouchableOpacity onPress={handleReduce} disabled={loading} activeOpacity={0.7}>
<ReduceIcon width={47} height={47} />
</TouchableOpacity>
@@ -41,14 +44,27 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
</Text>
</View>
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
<TouchableOpacity onPress={handleAdd} disabled={loading} activeOpacity={0.7}>
<AddIcon width={47} height={47} />
</TouchableOpacity>
</View>
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
<TouchableOpacity onPress={onFinish} disabled={loading} activeOpacity={0.8}>
<View style={styles.finishWrap}>
{loading ? (
<LinearGradient
colors={['#F69F7B', '#F99CC0']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.loadingPill, styles.finishDisabled]}
>
<ActivityIndicator size="small" color="#FFFFFF" />
</LinearGradient>
) : (
<BtnClicked width={87} height={57} />
)}
</View>
</TouchableOpacity>
</View>
</View>
@@ -92,4 +108,20 @@ const styles = StyleSheet.create({
position: 'absolute',
alignItems: 'center',
},
finishWrap: {
width: 87,
height: 57,
alignItems: 'center',
justifyContent: 'center',
},
finishDisabled: {
opacity: 0.7,
},
loadingPill: {
width: 87,
height: 57,
borderRadius: 28.5,
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -27,7 +27,7 @@ export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
* 默认回退到 prod避免误打到 localhost 导致真机“无法发起网络请求”)。
*/
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'prod';
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'dev';
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;

View File

@@ -5,7 +5,13 @@ import { Platform } from 'react-native';
import { httpJson } from '../utils/http';
import { APP_ENV } from '../constants/env';
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
import {
getDailyReminderSettings,
getLastRegisteredPushPayload,
getOrCreateClientUserId,
getUserProfileScoring,
setLastRegisteredPushPayload,
} from '../storage/appStorage';
import type { UserProfileScoring } from '../storage/appStorage';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
@@ -154,6 +160,40 @@ export async function registerPushToken(args: { pushToken: string }): Promise<vo
});
}
function isSystemNotificationPermissionGranted(status: unknown): boolean {
// iOS 可能出现 provisional临时授权在 Push 场景也应视为“可获取 token 并上报”
return status === 'granted' || status === 'provisional';
}
export async function ensurePushTokenRegisteredIfPermitted(): Promise<{ ok: boolean; reason: string }> {
// 只要系统权限已授权,就应尽早把 token 写入后端(不依赖用户在“每日提醒”里点确认)
const settings = await Notifications.getPermissionsAsync();
if (!isSystemNotificationPermissionGranted(settings.status)) return { ok: false, reason: 'permission_not_granted' };
const clientUserId = await getOrCreateClientUserId();
const token = await getExpoPushTokenOrThrow();
const env = toPushEnv(APP_ENV);
const appId = pickAppId();
// 去重规则(更严格):
// - 只有当 token + client_user_id + env + app_id 全都一致时才跳过
// - 避免出现“client_user_id 变化但 token 没变,导致后端绑定不更新”的问题
const last = await getLastRegisteredPushPayload().catch(() => null);
if (
last &&
last.pushToken === token &&
last.clientUserId === clientUserId &&
last.env === env &&
last.appId === appId
) {
return { ok: true, reason: 'already_registered' };
}
await registerPushToken({ pushToken: token });
await setLastRegisteredPushPayload({ pushToken: token, clientUserId, env, appId });
return { ok: true, reason: 'registered' };
}
export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise<PushPreferencesResponse> {
const clientUserId = await getOrCreateClientUserId();
const tz = pickTimezone();

View File

@@ -18,6 +18,9 @@ const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
const KEY_UI_THEME_MODE = 'ui.theme.mode';
const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state';
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
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
export type Reaction = 'like' | 'dislike';
@@ -69,6 +72,46 @@ export type DailyReminderSettings = {
pushEnabled: boolean;
};
export async function getLastRegisteredPushToken(): Promise<string | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_TOKEN);
return raw ? String(raw) : null;
}
export async function setLastRegisteredPushToken(token: string): Promise<void> {
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_TOKEN, String(token));
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_AT, new Date().toISOString());
}
export type LastRegisteredPushPayload = {
pushToken: string;
clientUserId: string;
env: 'dev' | 'prod';
appId: string;
savedAt: string; // ISO8601
};
export async function getLastRegisteredPushPayload(): Promise<LastRegisteredPushPayload | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_PAYLOAD);
if (!raw) return null;
try {
const obj = JSON.parse(raw) as Partial<LastRegisteredPushPayload>;
if (!obj || typeof obj !== 'object') return null;
if (!obj.pushToken || !obj.clientUserId || !obj.env || !obj.appId || !obj.savedAt) return null;
if (obj.env !== 'dev' && obj.env !== 'prod') return null;
return obj as LastRegisteredPushPayload;
} catch {
return null;
}
}
export async function setLastRegisteredPushPayload(payload: Omit<LastRegisteredPushPayload, 'savedAt'>): Promise<void> {
const savedAt = new Date().toISOString();
const full: LastRegisteredPushPayload = { ...payload, savedAt };
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_PAYLOAD, JSON.stringify(full));
// 同时写入旧 key便于兼容老逻辑/快速排查
await setLastRegisteredPushToken(payload.pushToken);
}
export type RecoFeedCacheItem = {
content_id: number;
text: string;
@@ -188,7 +231,7 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
}
export type FavoriteItem = {
favId: string; // 唯一标识,支持重复点赞同一文案
favId: string; // 唯一标识
id: string;
/**
* 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案)
@@ -200,13 +243,28 @@ export type FavoriteItem = {
};
export async function getFavorites(): Promise<FavoriteItem[]> {
return await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
const raw = await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
// 去重:同一条文案只保留最新的一条,防止重复点击喜欢导致弹窗重复展示
const seen = new Set<string>();
const deduped: FavoriteItem[] = [];
for (const item of raw) {
const key = String(item.id);
if (seen.has(key)) continue;
seen.add(key);
deduped.push(item);
}
// 若发现历史数据有重复,顺便写回清理后的版本
if (deduped.length !== raw.length) {
await setJson(KEY_FAVORITES_ITEMS, deduped);
}
return deduped;
}
export async function addFavorite(item: FavoriteItem): Promise<void> {
const list = await getFavorites();
// 允许重复点赞,不再根据 id 去重
const newList = [item, ...list];
// 幂等写入:同一条文案只保留一条(最新),避免重复点击喜欢产生重复项
const filtered = list.filter(x => String(x.id) !== String(item.id));
const newList = [item, ...filtered];
console.log('Adding to favorites:', JSON.stringify(item));
await setJson(KEY_FAVORITES_ITEMS, newList);
}

View File

@@ -7,7 +7,7 @@ import httpx
import redis
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.limits import rate_limit_push_by_ip
@@ -194,9 +194,10 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend
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
# 返回更新时间
# - 某些运行环境/驱动组合下commit 后访问 ORM 字段可能触发隐式 IO导致 async 下报 MissingGreenlet。
# - 这里直接用当前时间兜底(字段本身为可选,仅用于前端展示)。
updated_at_iso = datetime.now(timezone.utc).isoformat()
return PushPreferencesResponse(
client_user_id=req.client_user_id,
@@ -296,6 +297,7 @@ async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any]
"worker": {"ok": False, "worker_count": 0},
"beat": {"ok": False, "last_heartbeat_at": None, "age_seconds": None},
"db": {"ok": False, "push_send_log_latest_created_at": None},
"db_push_tokens": {"ok": False, "count": None, "latest": None},
"now_utc": datetime.now(timezone.utc).isoformat(),
}
@@ -342,5 +344,36 @@ async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any]
except Exception as e:
out["db"]["error"] = f"{type(e).__name__}: {e}"
# 4) DB查询 push_tokens 计数与最近一条(用于确认 /v1/push/register 是否真正落库)
try:
qcount = select(func.count()).select_from(PushToken)
rcount = await db.execute(qcount)
cnt = int(rcount.scalar_one() or 0)
qlatest = select(PushToken).order_by(PushToken.last_seen_at.desc()).limit(1)
rlatest = await db.execute(qlatest)
t = rlatest.scalar_one_or_none()
latest_obj = None
if t is not None:
tok = str(t.push_token or "")
masked = tok[:10] + "***" + tok[-6:] if len(tok) > 20 else (tok[:6] + "***" if tok else "")
latest_obj = {
"id": int(getattr(t, "id", 0) or 0),
"client_user_id": str(t.client_user_id),
"env": str(t.env),
"app_id": str(t.app_id),
"platform": str(t.platform),
"is_active": bool(t.is_active),
"last_seen_at": t.last_seen_at.isoformat() if t.last_seen_at else None,
"push_token_masked": masked,
}
out["db_push_tokens"]["ok"] = True
out["db_push_tokens"]["count"] = cnt
out["db_push_tokens"]["latest"] = latest_obj
except Exception as e:
out["db_push_tokens"]["error"] = f"{type(e).__name__}: {e}"
return out

Binary file not shown.

Binary file not shown.

View File

@@ -39,6 +39,7 @@
- **已完成编码(阶段性)**
- 客户端:新增 `client_user_id`UUID v4生成与持久化每日提醒次数范围修正为 **05**0 表示关闭)
- 客户端Onboarding 结束页(每日提醒)在用户选择次数 > 0 时**直接触发系统权限申请**;授权后获取 Expo Push Token 并调用后端 `register/preferences`(移除单独的 push 引导页)
- 客户端Onboarding 问卷完成后“开通推送权限”流程增加 **loading 态**(完成按钮转圈 + 全页禁用交互,避免重复触发/重复上报)
- 客户端:个人主页“每日提醒”弹窗移除测试模式强制无权限逻辑,改为真实读取系统权限;并在开关/点击 OK 时同步后端偏好
- 客户端:新增推送接口封装 `client/src/services/pushApi.ts`token 获取、register/preferences/get、自动上报时区与 locale并携带用户画像供后端 Push 模板使用)
- 后端:新增 Push 数据模型 + Alembic 迁移(`push_tokens` / `push_preferences` / `push_send_log`
@@ -89,6 +90,8 @@
- 推送 entitlements 的 `aps-environment` 已切到 `production`(用于 TestFlight/线上包)
- 清理未接入编译的 WidgetKit 骨架残留:移除磁盘上的 `client/ios/MindfulnessWidget/` 文件,并从 `client/ios/client.xcodeproj/project.pbxproj` 删除对应工程引用(避免 Xcode 显示幽灵文件)
- 修复 Xcode Archive 偶发显示 “Generic Xcode Archive”在共享 scheme `Hey Mama` 的 Archive Post-actions 自动补齐 `.xcarchive/Info.plist``ApplicationProperties`,并在缺失时补齐 `Name`/`SchemeName` + 自检提示(根治 Organizer 无法识别主 App、无法分发/上传 TestFlight 的问题)
- Widget 名称与描述支持多语言TC/EN默认 ENWidget Extension 增加 `Localizable.strings``en.lproj` / `zh-Hant.lproj``EmotionWidget.swift` 使用本地化 key 作为 `.configurationDisplayName/.description`
- 个人主页弹窗:小工具入口**暂时隐藏**锁屏小工具说明;桌面小工具引导弹窗标题(繁中/TC更新为“**如何加入小工具**”(并统一弹窗标题使用该文案);品牌文案“正念”改为 **Hey Mama**(含引导搜索词与 Widget 标题)
## Text Wrap
@@ -169,6 +172,10 @@
- `spec_kit/Text Wrap/modules/integration/tasks.md`
- **接入情况**
- HomeAPP已在 `client/app/(app)/home.tsx` 接入 `wrapText()` 渲染 `wrappedText`(含 `\n`
- iOS WidgetWidgetKitApp 侧在写入 `widget.dailyReco.v1` 缓存时,额外生成 `wrapped_text_by_family`small/medium/large并写入 App GroupWidget 侧按 `WidgetFamily` 优先读取该字段渲染(保证换行一致且无需在 Extension 内跑 JS
- **近期变更**
- Widget 接入:`client/src/modules/dailyWidgetReco/index.ts` 生成 `wrapped_text_by_family``client/ios/情绪小组件/EmotionWidget.swift` 按 family 读取;`client/app/(app)/home.tsx` 前台触发一次“尽力而为”的补齐/刷新
- Home 排版风格微调:支持 `scoringOverrides`,在 Home 里对 TC 做“更偏好标点停顿/更好看”的权重与理想宽度微调(不影响默认 v1
## Splash Consent