Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fcc64f6e3 | ||
|
|
9d6829eceb | ||
|
|
4838bcef4b | ||
|
|
0fcf85a081 | ||
|
|
62fcc4bfce | ||
|
|
eef5210c99 | ||
|
|
402cbf90eb | ||
| decc7f9564 | |||
| 173cee75d5 | |||
|
|
076bd5636f | ||
|
|
154f347ddb | ||
|
|
dec3ac82e1 |
@@ -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,
|
||||
@@ -128,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;
|
||||
}
|
||||
@@ -140,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。
|
||||
|
||||
@@ -87,6 +87,17 @@ export default function RootLayout() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
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);
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
@@ -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 { ensurePushTokenRegisteredIfPermitted, 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 {
|
||||
|
||||
@@ -2540,30 +2540,30 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
|
||||
EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
|
||||
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
||||
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
|
||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||
EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b
|
||||
EXNotifications: 9eec98712cc814ceff916d876cb53859003b0597
|
||||
Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710
|
||||
expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d
|
||||
expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3
|
||||
expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d
|
||||
EXManifests: 83ef0844fcf06d6099b12a7bdbd7d36fc0e1dd16
|
||||
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
|
||||
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
|
||||
expo-dev-client: 12ef7d5b14d93e309922acea78dcd851db583a87
|
||||
expo-dev-launcher: 47994056008ffdc30a6a5e328a375b3e30a8db05
|
||||
expo-dev-menu: ea4fb803ace52e60d7cd8060c7cd379612a140b2
|
||||
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
||||
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
|
||||
ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322
|
||||
ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a
|
||||
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
|
||||
ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47
|
||||
ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780
|
||||
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
|
||||
ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58
|
||||
ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37
|
||||
ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd
|
||||
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
|
||||
ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d
|
||||
ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52
|
||||
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
|
||||
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
||||
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
|
||||
ExpoDevice: 0773c782b055558ca9b40b74aa4a8133a66cd0d2
|
||||
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
||||
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
||||
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
||||
ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0
|
||||
ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86
|
||||
ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959
|
||||
ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485
|
||||
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
|
||||
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
|
||||
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
|
||||
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
|
||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||
@@ -2571,72 +2571,72 @@ SPEC CHECKSUMS:
|
||||
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
||||
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
||||
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
||||
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
|
||||
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
|
||||
React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
|
||||
React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
|
||||
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90
|
||||
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151
|
||||
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac
|
||||
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c
|
||||
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
|
||||
React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
|
||||
React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
|
||||
React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
|
||||
React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
|
||||
React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
|
||||
React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
|
||||
React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
|
||||
React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
|
||||
React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
|
||||
React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
|
||||
React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
|
||||
React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
|
||||
React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
|
||||
React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
|
||||
React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
|
||||
React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
|
||||
React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
|
||||
React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
|
||||
React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
|
||||
React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
|
||||
React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
|
||||
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
|
||||
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
|
||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
||||
React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
|
||||
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c
|
||||
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d
|
||||
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983
|
||||
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4
|
||||
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86
|
||||
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d
|
||||
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28
|
||||
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24
|
||||
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf
|
||||
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc
|
||||
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7
|
||||
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795
|
||||
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0
|
||||
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669
|
||||
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51
|
||||
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0
|
||||
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb
|
||||
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270
|
||||
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca
|
||||
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a
|
||||
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd
|
||||
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108
|
||||
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c
|
||||
react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd
|
||||
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd
|
||||
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
|
||||
React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
|
||||
React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
|
||||
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f
|
||||
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490
|
||||
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
|
||||
React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
|
||||
React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
|
||||
React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
|
||||
React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
|
||||
React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
|
||||
React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
|
||||
React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
|
||||
React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
|
||||
React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
|
||||
React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
|
||||
React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
|
||||
React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
|
||||
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af
|
||||
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28
|
||||
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264
|
||||
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6
|
||||
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266
|
||||
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac
|
||||
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31
|
||||
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000
|
||||
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b
|
||||
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a
|
||||
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461
|
||||
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e
|
||||
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
|
||||
React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
|
||||
React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
|
||||
React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
|
||||
React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
|
||||
React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
|
||||
React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
|
||||
React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
|
||||
React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
|
||||
React-utils: 6e2035b53d087927768649a11a26c4e092448e34
|
||||
ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
|
||||
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
|
||||
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
||||
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490
|
||||
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812
|
||||
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613
|
||||
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4
|
||||
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c
|
||||
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562
|
||||
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7
|
||||
React-timing: 03c7217455d2bff459b27a3811be25796b600f47
|
||||
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
|
||||
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
|
||||
ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f
|
||||
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
||||
RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f
|
||||
RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
|
||||
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
||||
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
||||
RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0
|
||||
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
|
||||
RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125
|
||||
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||
RNWorklets: 28ee7370ca8da356fcc914e3e68b97e9752196d2
|
||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||
|
||||
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
||||
|
||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 143 KiB |
@@ -7,10 +7,10 @@ import { httpJson } from '../utils/http';
|
||||
import { APP_ENV } from '../constants/env';
|
||||
import {
|
||||
getDailyReminderSettings,
|
||||
getLastRegisteredPushToken,
|
||||
getLastRegisteredPushPayload,
|
||||
getOrCreateClientUserId,
|
||||
getUserProfileScoring,
|
||||
setLastRegisteredPushToken,
|
||||
setLastRegisteredPushPayload,
|
||||
} from '../storage/appStorage';
|
||||
import type { UserProfileScoring } from '../storage/appStorage';
|
||||
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
|
||||
@@ -160,19 +160,37 @@ 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 (settings.status !== 'granted') return { ok: false, reason: 'permission_not_granted' };
|
||||
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 未变化则不重复上报(减少网络与日志噪音)
|
||||
const last = await getLastRegisteredPushToken().catch(() => null);
|
||||
if (last && String(last) === String(token)) return { ok: true, reason: 'already_registered' };
|
||||
// 去重规则(更严格):
|
||||
// - 只有当 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 setLastRegisteredPushToken(token);
|
||||
await setLastRegisteredPushPayload({ pushToken: token, clientUserId, env, appId });
|
||||
return { ok: true, reason: 'registered' };
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +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';
|
||||
const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt';
|
||||
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';
|
||||
@@ -81,6 +82,36 @@ export async function setLastRegisteredPushToken(token: string): Promise<void> {
|
||||
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;
|
||||
|
||||
31
server/alembic/versions/0003_add_push_send_log_payload.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""add push_send_log payload snapshot
|
||||
|
||||
Revision ID: 0003_add_push_send_log_payload
|
||||
Revises: 0002_init_push_tables
|
||||
Create Date: 2026-02-12
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0003_add_push_send_log_payload"
|
||||
down_revision = "0002_init_push_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("push_send_log", sa.Column("content_id", sa.Integer(), nullable=True, comment="推送文案内容 ID(可选)"))
|
||||
op.add_column("push_send_log", sa.Column("title", sa.String(length=128), nullable=True, comment="推送标题(可选)"))
|
||||
op.add_column("push_send_log", sa.Column("body", sa.Text(), nullable=True, comment="推送正文(可选)"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("push_send_log", "body")
|
||||
op.drop_column("push_send_log", "title")
|
||||
op.drop_column("push_send_log", "content_id")
|
||||
|
||||
@@ -14,6 +14,53 @@ router = APIRouter(prefix="/v1/legal", tags=["legal"])
|
||||
|
||||
INTERNAL_SENTINEL = "__internal__"
|
||||
|
||||
# 技术支持页文案(EN / TC)
|
||||
SUPPORT_MD = """Dear Mama | Technical Support
|
||||
Last updated: February 2026
|
||||
|
||||
If you need technical support for Dear Mama, please contact us:
|
||||
- Support email: leitinglan@project-c.org
|
||||
|
||||
Support scope (including but not limited to):
|
||||
- App installation or update issues
|
||||
- App crashes, freezes, or abnormal behavior
|
||||
- Notification or widget related issues
|
||||
- Difficulty accessing privacy policy or terms pages
|
||||
|
||||
To help us process your request faster, please include:
|
||||
- Device model and OS version
|
||||
- App version
|
||||
- A brief issue description and occurrence time
|
||||
- Screenshots or screen recording (if available)
|
||||
|
||||
Service commitment:
|
||||
- We generally respond within 3-5 business days
|
||||
- Emergency availability may vary by holidays and local time
|
||||
|
||||
---
|
||||
Dear Mama|技術支援
|
||||
最後更新日期:2026 年 2 月
|
||||
|
||||
如需 Dear Mama 的技術支援,請透過以下方式聯絡我們:
|
||||
- 支援信箱:leitinglan@project-c.org
|
||||
|
||||
支援範圍(包含但不限於):
|
||||
- App 安裝或更新問題
|
||||
- App 閃退、卡頓或異常行為
|
||||
- 通知或小組件相關問題
|
||||
- 隱私政策或使用條款頁面無法開啟
|
||||
|
||||
為了加快處理,建議提供:
|
||||
- 裝置型號與系統版本
|
||||
- App 版本號
|
||||
- 問題描述與發生時間
|
||||
- 截圖或螢幕錄影(如有)
|
||||
|
||||
服務承諾:
|
||||
- 一般會在 3-5 個工作日內回覆
|
||||
- 節假日或時區差異時,回覆時間可能延長
|
||||
"""
|
||||
|
||||
|
||||
# 当前多语言仅支持 EN / TC(繁体)
|
||||
ResolvedLang = Literal["en", "tc"]
|
||||
@@ -129,3 +176,17 @@ async def get_terms_of_use(request: Request) -> HTMLResponse:
|
||||
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("/support", response_class=HTMLResponse)
|
||||
async def get_support_page(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
技术支持页面(用于 App 审核的可访问 URL)。
|
||||
"""
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
lang = _resolve_lang(accept_language)
|
||||
content, resolved = choose_content_by_lang(SUPPORT_MD, lang)
|
||||
title = "Dear Mama | Technical Support" if resolved == "en" else "Dear Mama|技術支援"
|
||||
page = render_as_simple_html(title=title, content=content)
|
||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -153,6 +153,32 @@ async def register(req: PushRegisterRequest, db: AsyncSession = Depends(get_db))
|
||||
token.is_active = True
|
||||
token.last_seen_at = _ensure_utc(now)
|
||||
|
||||
# 额外:尽早写入/补齐时区与语言(用于按用户时区生成排程)
|
||||
# 说明:
|
||||
# - 用户首次授权后会立即调用 /register,但不一定马上进入“每日提醒”确认页
|
||||
# - 若 push_preferences 里 timezone 为空,会导致排程回退到 UTC,体验不符合预期
|
||||
if req.device_meta:
|
||||
tz = (req.device_meta.timezone or "").strip() or None
|
||||
loc = (req.device_meta.locale or "").strip() or None
|
||||
if tz or loc:
|
||||
qpref = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id)
|
||||
rpref = await db.execute(qpref)
|
||||
pref = rpref.scalar_one_or_none()
|
||||
if pref is None:
|
||||
pref = PushPreference(
|
||||
client_user_id=req.client_user_id,
|
||||
enabled=False,
|
||||
times_per_day=0,
|
||||
timezone=tz,
|
||||
locale=loc,
|
||||
)
|
||||
db.add(pref)
|
||||
else:
|
||||
if tz and not (pref.timezone or "").strip():
|
||||
pref.timezone = tz
|
||||
if loc and not (pref.locale or "").strip():
|
||||
pref.locale = loc
|
||||
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -187,16 +213,20 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend
|
||||
else:
|
||||
pref.enabled = enabled
|
||||
pref.times_per_day = times
|
||||
# 注意:只在客户端显式传入时覆盖,避免把已保存的 timezone/locale 清空导致排程回退到 UTC
|
||||
if req.timezone is not None:
|
||||
pref.timezone = req.timezone
|
||||
if req.locale is not None:
|
||||
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
|
||||
# 返回更新时间:
|
||||
# - 某些运行环境/驱动组合下,commit 后访问 ORM 字段可能触发隐式 IO,导致 async 下报 MissingGreenlet。
|
||||
# - 这里直接用当前时间兜底(字段本身为可选,仅用于前端展示)。
|
||||
updated_at_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return PushPreferencesResponse(
|
||||
client_user_id=req.client_user_id,
|
||||
@@ -296,6 +326,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 +373,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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, Index, SmallInteger, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import Date, DateTime, Index, Integer, SmallInteger, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -33,5 +33,10 @@ class PushSendLog(Base):
|
||||
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="失败原因(可选)")
|
||||
|
||||
# 发送内容快照(用于观测 + 去重)
|
||||
content_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="推送文案内容 ID(可选)")
|
||||
title: Mapped[str | None] = mapped_column(String(length=128), nullable=True, comment="推送标题(可选)")
|
||||
body: Mapped[str | None] = mapped_column(Text, nullable=True, comment="推送正文(可选)")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), comment="创建时间")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from celery import current_app, shared_task
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.models.push_preference import PushPreference
|
||||
@@ -44,7 +44,8 @@ def _pick_reco_locale(pref_locale: Optional[str]) -> str:
|
||||
|
||||
|
||||
def _pick_title(locale: str) -> str:
|
||||
return "每日提醒" if str(locale) == "tc" else "Daily Reminder"
|
||||
# 需求:tc 语言使用繁体标题
|
||||
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]:
|
||||
@@ -67,7 +68,11 @@ async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict
|
||||
|
||||
def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
|
||||
"""
|
||||
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动)。
|
||||
将窗口均匀切分为 n 个区间,并在每段内取“中点 + 受限抖动”的时间点。
|
||||
|
||||
目的:
|
||||
- 尽量均匀分布(避免相邻两条推送随机到非常接近的时间)
|
||||
- 仍保留一定随机性,避免过于机械
|
||||
"""
|
||||
|
||||
if n <= 0:
|
||||
@@ -84,8 +89,15 @@ def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[dat
|
||||
if seg <= 0:
|
||||
out.append(seg_start)
|
||||
continue
|
||||
jitter = random.random() * seg
|
||||
out.append(seg_start + timedelta(seconds=jitter))
|
||||
|
||||
# 受限抖动:在每段的 [25%, 75%] 区间内取点
|
||||
# 这样相邻两段的最小间隔为 50% 段长,能显著减少“随机挤在一起”。
|
||||
mid = seg_start + timedelta(seconds=seg * 0.5)
|
||||
jitter = (random.random() - 0.5) * (seg * 0.5) # [-0.25*seg, +0.25*seg]
|
||||
out.append(mid + timedelta(seconds=jitter))
|
||||
|
||||
# 保序(理论上天然有序,这里再保险)
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
@@ -167,6 +179,7 @@ async def _generate_schedule_once(*, now_utc: datetime, max_users: int = 5000) -
|
||||
created += 1
|
||||
|
||||
# 投递 ETA 发送任务
|
||||
try:
|
||||
current_app.send_task(
|
||||
"tasks.push.send_scheduled",
|
||||
kwargs={
|
||||
@@ -177,6 +190,14 @@ async def _generate_schedule_once(*, now_utc: datetime, max_users: int = 5000) -
|
||||
eta=dt_utc,
|
||||
)
|
||||
scheduled += 1
|
||||
except Exception as e:
|
||||
# 关键:如果投递失败(例如 broker 短暂不可用),不要让 log 永远卡在 scheduled
|
||||
log.status = "failed"
|
||||
log.error = f"enqueue_failed:{type(e).__name__}"
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
return {"created": created, "scheduled": scheduled}
|
||||
|
||||
@@ -209,6 +230,34 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
|
||||
return {"status": "noop", "reason": "no_log"}
|
||||
if str(log.status) == "sent":
|
||||
return {"status": "noop", "reason": "already_sent"}
|
||||
if str(log.status) not in ("scheduled", "sending"):
|
||||
# 例如 failed/skipped:不再重复尝试
|
||||
return {"status": "noop", "reason": f"not_retryable:{log.status}"}
|
||||
|
||||
# 原子抢占:避免重复发送
|
||||
# - scheduled:正常抢占 scheduled -> sending
|
||||
# - sending:如果长时间卡在 sending(进程崩溃/网络异常等),允许“超时接管”继续执行
|
||||
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
steal_cutoff = now_utc_naive - timedelta(minutes=10)
|
||||
res = await session.execute(
|
||||
update(PushSendLog)
|
||||
.where(
|
||||
PushSendLog.id == log.id,
|
||||
(
|
||||
(PushSendLog.status == "scheduled")
|
||||
| (
|
||||
(PushSendLog.status == "sending")
|
||||
& (PushSendLog.sent_at.is_(None))
|
||||
& (PushSendLog.scheduled_at <= steal_cutoff)
|
||||
)
|
||||
),
|
||||
)
|
||||
.values(status="sending", error=None)
|
||||
)
|
||||
await session.commit()
|
||||
if (res.rowcount or 0) <= 0:
|
||||
return {"status": "noop", "reason": "already_in_progress_or_processed"}
|
||||
log.status = "sending"
|
||||
|
||||
# 2) 当前偏好检查(用户可能中途关闭/改次数)
|
||||
qpref = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
|
||||
@@ -235,6 +284,7 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
|
||||
await session.commit()
|
||||
return {"status": "failed", "reason": "no_active_token"}
|
||||
|
||||
try:
|
||||
# 4) 生成文案(复用推荐模块 push 场景)
|
||||
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
|
||||
title = _pick_title(reco_locale)
|
||||
@@ -247,34 +297,76 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
|
||||
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
|
||||
)
|
||||
|
||||
# 直接复用 reco 的 Celery 任务实现(同步函数)
|
||||
from app.tasks.reco import generate as reco_generate
|
||||
# 关键:这里不能调用 tasks.reco.generate(内部会 asyncio.run),否则会嵌套事件循环崩溃。
|
||||
from app.tasks.reco import run_reco_payload_async
|
||||
|
||||
reco_payload = reco_generate(scene="push", user_profile=user_profile.model_dump(), k=1, locale=reco_locale)
|
||||
body = ""
|
||||
# 去重:用户推送过的内容尽量不再推送
|
||||
# 说明:
|
||||
# - 依赖 push_send_log.content_id(需先完成对应 DB 迁移)
|
||||
# - 为避免历史过长导致 already_recommended_ids 过大,这里取“最近若干条已推送内容”近似全量去重
|
||||
used_ids: list[int] = []
|
||||
try:
|
||||
qused = (
|
||||
select(PushSendLog.content_id)
|
||||
.where(
|
||||
PushSendLog.client_user_id == client_user_id,
|
||||
PushSendLog.content_id.is_not(None),
|
||||
PushSendLog.id != log.id,
|
||||
)
|
||||
# 优先排除最近发送过的内容
|
||||
.order_by(PushSendLog.local_date.desc(), PushSendLog.slot_index.desc())
|
||||
.limit(5000)
|
||||
)
|
||||
rused = await session.execute(qused)
|
||||
used_ids = [int(x) for x in rused.scalars().all() if x is not None]
|
||||
except Exception:
|
||||
used_ids = []
|
||||
|
||||
body = ""
|
||||
picked_content_id: int | None = None
|
||||
try:
|
||||
reco_payload = await run_reco_payload_async(
|
||||
scene="push",
|
||||
user_profile=user_profile,
|
||||
k=3,
|
||||
locale=reco_locale,
|
||||
already_recommended_ids=used_ids,
|
||||
)
|
||||
items = (reco_payload or {}).get("items") or []
|
||||
if items and isinstance(items, list):
|
||||
body = str(items[0].get("text") or "").strip()
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
cid = it.get("content_id")
|
||||
txt = str(it.get("text") or "").strip()
|
||||
if not txt:
|
||||
continue
|
||||
if cid is not None:
|
||||
try:
|
||||
cid_i = int(cid)
|
||||
except Exception:
|
||||
cid_i = None
|
||||
else:
|
||||
cid_i = None
|
||||
if cid_i is not None and cid_i in used_ids:
|
||||
continue
|
||||
picked_content_id = cid_i
|
||||
body = txt
|
||||
break
|
||||
except Exception:
|
||||
body = ""
|
||||
|
||||
if not body:
|
||||
body = "给自己一句温柔的话。"
|
||||
# tc 语言兜底文案使用繁体
|
||||
body = "給自己一句溫柔的話。" if reco_locale == "tc" else "给自己一句温柔的话。"
|
||||
|
||||
# 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:
|
||||
@@ -297,8 +389,17 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
|
||||
log.status = "sent"
|
||||
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
log.error = None
|
||||
log.title = title
|
||||
log.body = body
|
||||
log.content_id = picked_content_id
|
||||
await session.commit()
|
||||
return {"status": "sent", "expo": expo_res}
|
||||
except Exception as e:
|
||||
# 兜底:任何未预期异常都不要让状态卡在 sending
|
||||
log.status = "failed"
|
||||
log.error = f"unexpected:{type(e).__name__}"
|
||||
await session.commit()
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
|
||||
@shared_task(name="tasks.push.send_scheduled")
|
||||
@@ -310,3 +411,50 @@ def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) ->
|
||||
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)))
|
||||
|
||||
|
||||
@shared_task(name="tasks.push.requeue_overdue")
|
||||
def requeue_overdue(*, grace_seconds: int = 300, limit: int = 200) -> dict[str, Any]:
|
||||
"""
|
||||
补偿任务:扫描“已到时间但仍处于 scheduled”的记录并重新投递发送任务。
|
||||
|
||||
目的:
|
||||
- 覆盖 broker 短暂不可用、worker 重启、ETA 任务丢失等导致的“scheduled 卡住”
|
||||
- 与 send_scheduled 内部的原子状态抢占配合,避免重复发送
|
||||
"""
|
||||
|
||||
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
cutoff = now_utc_naive - timedelta(seconds=int(grace_seconds))
|
||||
|
||||
async def _run() -> dict[str, Any]:
|
||||
requeued = 0
|
||||
async with AsyncSessionLocal() as session:
|
||||
q = (
|
||||
select(PushSendLog)
|
||||
.where(
|
||||
PushSendLog.status.in_(("scheduled", "sending")),
|
||||
PushSendLog.sent_at.is_(None),
|
||||
PushSendLog.scheduled_at <= cutoff,
|
||||
)
|
||||
.order_by(PushSendLog.scheduled_at.asc())
|
||||
.limit(int(limit))
|
||||
)
|
||||
rows = await session.execute(q)
|
||||
logs = list(rows.scalars().all())
|
||||
for log in logs:
|
||||
try:
|
||||
current_app.send_task(
|
||||
"tasks.push.send_scheduled",
|
||||
kwargs={
|
||||
"client_user_id": str(log.client_user_id),
|
||||
"local_date": str(log.local_date),
|
||||
"slot_index": int(log.slot_index),
|
||||
},
|
||||
)
|
||||
requeued += 1
|
||||
except Exception:
|
||||
# 忽略单条投递失败,交给下一轮补偿
|
||||
continue
|
||||
return {"status": "ok", "requeued": requeued, "cutoff": cutoff.isoformat()}
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
@@ -53,6 +53,45 @@ async def _run_reco_async(
|
||||
)
|
||||
|
||||
|
||||
async def run_reco_payload_async(
|
||||
*,
|
||||
scene: Scene,
|
||||
user_profile: UserProfileV1_2,
|
||||
already_recommended_ids: Optional[list[Any]] = None,
|
||||
touched_or_viewed_ids: Optional[list[Any]] = None,
|
||||
k: Optional[int] = None,
|
||||
now: Optional[datetime] = None,
|
||||
locale: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
在“已有事件循环”内运行推荐并返回 payload。
|
||||
|
||||
用途:
|
||||
- 供 Push 等 async 任务内部调用,避免 `asyncio.run()` 嵌套导致 RuntimeError
|
||||
- 也便于未来在 API/任务间复用
|
||||
"""
|
||||
|
||||
effective_now = _ensure_now(now)
|
||||
effective_locale = _ensure_locale(locale)
|
||||
|
||||
# k 默认按场景(与 generate 保持一致)
|
||||
if k is None:
|
||||
k_i = 30 if scene == "feed" else 1
|
||||
else:
|
||||
k_i = int(k)
|
||||
|
||||
result = await _run_reco_async(
|
||||
scene=scene,
|
||||
user_profile=user_profile,
|
||||
already_recommended_ids=list(already_recommended_ids or []),
|
||||
touched_or_viewed_ids=list(touched_or_viewed_ids or []),
|
||||
k=int(k_i),
|
||||
now=effective_now,
|
||||
locale=effective_locale,
|
||||
)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
def _run_reco_sync(
|
||||
*,
|
||||
scene: Scene,
|
||||
|
||||
@@ -53,10 +53,19 @@ celery_app.conf.beat_schedule = {
|
||||
},
|
||||
"push-generate-daily-schedule": {
|
||||
"task": "tasks.push.generate_daily_schedule",
|
||||
"schedule": crontab(minute=10, hour=0),
|
||||
# 由“每天一次”调整为“每 2 小时一次”(UTC)
|
||||
"schedule": crontab(minute=10, hour="*/2"),
|
||||
"kwargs": {"max_users": 5000},
|
||||
"options": {"queue": f"{prefix}:celery"},
|
||||
}
|
||||
,
|
||||
# 补偿:每 5 分钟扫描一次 overdue scheduled 并重投递
|
||||
"push-requeue-overdue": {
|
||||
"task": "tasks.push.requeue_overdue",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"kwargs": {"grace_seconds": 300, "limit": 200},
|
||||
"options": {"queue": f"{prefix}:celery"},
|
||||
},
|
||||
}
|
||||
|
||||
# 自动发现任务(约定:导入 app.tasks 触发其内部对子模块的显式导入)
|
||||
|
||||
BIN
server/celerybeat-schedule-shm
Normal file
BIN
server/celerybeat-schedule-wal
Normal file
@@ -202,6 +202,7 @@
|
||||
- 后端新增内置协议内容页:
|
||||
- `GET /v1/legal/privacy`
|
||||
- `GET /v1/legal/terms`
|
||||
- `GET /v1/legal/support`(技术支持页面,提供审核可用的公开支持信息)
|
||||
- 客户端新增协议接口封装 `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`
|
||||
|
||||