Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ad21da246 | ||
|
|
43e33eb991 | ||
|
|
7fcc64f6e3 | ||
|
|
9d6829eceb | ||
|
|
4838bcef4b | ||
|
|
0fcf85a081 | ||
|
|
62fcc4bfce | ||
|
|
eef5210c99 | ||
|
|
402cbf90eb |
|
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 |
@@ -2540,30 +2540,30 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
|
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
||||||
EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
|
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
|
||||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||||
EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b
|
EXManifests: 83ef0844fcf06d6099b12a7bdbd7d36fc0e1dd16
|
||||||
EXNotifications: 9eec98712cc814ceff916d876cb53859003b0597
|
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
|
||||||
Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710
|
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
|
||||||
expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d
|
expo-dev-client: 12ef7d5b14d93e309922acea78dcd851db583a87
|
||||||
expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3
|
expo-dev-launcher: 47994056008ffdc30a6a5e328a375b3e30a8db05
|
||||||
expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d
|
expo-dev-menu: ea4fb803ace52e60d7cd8060c7cd379612a140b2
|
||||||
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
||||||
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
|
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
||||||
ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322
|
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
|
||||||
ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a
|
ExpoDevice: 0773c782b055558ca9b40b74aa4a8133a66cd0d2
|
||||||
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
|
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
||||||
ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47
|
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
||||||
ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780
|
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
||||||
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
|
ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0
|
||||||
ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58
|
ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86
|
||||||
ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37
|
ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959
|
||||||
ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd
|
ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485
|
||||||
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
|
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
|
||||||
ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d
|
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
|
||||||
ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52
|
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
|
||||||
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
|
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
|
||||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||||
@@ -2571,72 +2571,72 @@ SPEC CHECKSUMS:
|
|||||||
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
||||||
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
||||||
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
||||||
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
|
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90
|
||||||
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
|
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151
|
||||||
React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
|
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac
|
||||||
React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
|
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c
|
||||||
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
|
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
|
||||||
React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
|
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c
|
||||||
React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
|
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d
|
||||||
React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
|
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983
|
||||||
React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
|
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4
|
||||||
React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
|
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86
|
||||||
React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
|
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d
|
||||||
React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
|
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28
|
||||||
React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
|
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24
|
||||||
React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
|
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf
|
||||||
React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
|
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc
|
||||||
React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
|
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7
|
||||||
React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
|
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795
|
||||||
React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
|
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0
|
||||||
React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
|
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669
|
||||||
React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
|
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51
|
||||||
React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
|
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0
|
||||||
React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
|
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb
|
||||||
React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
|
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270
|
||||||
React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
|
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca
|
||||||
React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
|
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a
|
||||||
React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
|
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd
|
||||||
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
|
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108
|
||||||
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
|
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c
|
||||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd
|
||||||
React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
|
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd
|
||||||
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
|
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
|
||||||
React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
|
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f
|
||||||
React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
|
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490
|
||||||
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
|
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
|
||||||
React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
|
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af
|
||||||
React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
|
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28
|
||||||
React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
|
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264
|
||||||
React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
|
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6
|
||||||
React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
|
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266
|
||||||
React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
|
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac
|
||||||
React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
|
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31
|
||||||
React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
|
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000
|
||||||
React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
|
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b
|
||||||
React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
|
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a
|
||||||
React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
|
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461
|
||||||
React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
|
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e
|
||||||
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
|
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
|
||||||
React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
|
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490
|
||||||
React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
|
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812
|
||||||
React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
|
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613
|
||||||
React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
|
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4
|
||||||
React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
|
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c
|
||||||
React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
|
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562
|
||||||
React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
|
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7
|
||||||
React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
|
React-timing: 03c7217455d2bff459b27a3811be25796b600f47
|
||||||
React-utils: 6e2035b53d087927768649a11a26c4e092448e34
|
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
|
||||||
ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
|
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
|
||||||
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
|
ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f
|
||||||
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||||
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||||
RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f
|
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
|
||||||
RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
|
RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125
|
||||||
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||||
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||||
RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0
|
RNWorklets: 28ee7370ca8da356fcc914e3e68b97e9752196d2
|
||||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||||
|
|
||||||
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 143 KiB |
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")
|
||||||
|
|
||||||
@@ -7,16 +7,70 @@ from fastapi.responses import HTMLResponse
|
|||||||
from pydantic import BaseModel, HttpUrl
|
from pydantic import BaseModel, HttpUrl
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.legal_docs import PRIVACY_POLICY_MD, TERMS_OF_USE_MD, choose_content_by_lang, render_as_simple_html
|
from app.legal_docs import (
|
||||||
|
PRIVACY_POLICY_MD,
|
||||||
|
TERMS_OF_USE_MD,
|
||||||
|
choose_content_by_lang,
|
||||||
|
render_as_simple_html,
|
||||||
|
split_bilingual_markdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/legal", tags=["legal"])
|
router = APIRouter(prefix="/v1/legal", tags=["legal"])
|
||||||
|
|
||||||
INTERNAL_SENTINEL = "__internal__"
|
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(繁体)
|
# 当前多语言仅支持 EN / TC(繁体)
|
||||||
ResolvedLang = Literal["en", "tc"]
|
ResolvedLang = Literal["en", "tc"]
|
||||||
|
BILINGUAL_CONTENT_LANGUAGE = "en, zh-Hant"
|
||||||
|
|
||||||
|
|
||||||
class LegalLinksResponse(BaseModel):
|
class LegalLinksResponse(BaseModel):
|
||||||
@@ -36,13 +90,23 @@ def _resolve_lang(accept_language: Optional[str]) -> ResolvedLang:
|
|||||||
|
|
||||||
if not accept_language:
|
if not accept_language:
|
||||||
return "en"
|
return "en"
|
||||||
s = accept_language.lower()
|
|
||||||
|
|
||||||
# 目前只支持 EN / TC:只要是中文或显式 tc,都归到 tc
|
# 按语言优先级顺序逐项解析,避免“只要包含 zh 就全判 tc”。
|
||||||
if "tc" in s:
|
# 例如:en-US,en;q=0.9,zh-CN;q=0.8 应命中 en,而不是 tc。
|
||||||
return "tc"
|
items = [seg.strip().lower() for seg in accept_language.split(",") if seg.strip()]
|
||||||
if "zh" in s or "hant" in s or "tw" in s or "hk" in s or "mo" in s:
|
for item in items:
|
||||||
return "tc"
|
lang_tag = item.split(";", 1)[0].strip()
|
||||||
|
if not lang_tag:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if lang_tag == "tc":
|
||||||
|
return "tc"
|
||||||
|
if lang_tag.startswith(("zh-hant", "zh-tw", "zh-hk", "zh-mo")):
|
||||||
|
return "tc"
|
||||||
|
if lang_tag.startswith("zh"):
|
||||||
|
return "tc"
|
||||||
|
if lang_tag.startswith("en"):
|
||||||
|
return "en"
|
||||||
|
|
||||||
return "en"
|
return "en"
|
||||||
|
|
||||||
@@ -85,6 +149,18 @@ def _normalize_internal_url(request: Request, url: str, internal_path: str) -> s
|
|||||||
return _join_base_url(str(request.base_url), internal_path)
|
return _join_base_url(str(request.base_url), internal_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_bilingual_content(text: str) -> str:
|
||||||
|
"""
|
||||||
|
固定输出双语协议内容:EN 在前,TC 在后。
|
||||||
|
若缺少 TC,则仅返回 EN。
|
||||||
|
"""
|
||||||
|
|
||||||
|
en, tc = split_bilingual_markdown(text)
|
||||||
|
if not tc:
|
||||||
|
return en
|
||||||
|
return f"{en}\n\n---\n{tc}"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/links", response_model=LegalLinksResponse)
|
@router.get("/links", response_model=LegalLinksResponse)
|
||||||
async def get_legal_links(request: Request) -> LegalLinksResponse:
|
async def get_legal_links(request: Request) -> LegalLinksResponse:
|
||||||
"""
|
"""
|
||||||
@@ -109,11 +185,18 @@ async def get_privacy_policy(request: Request) -> HTMLResponse:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
accept_language = request.headers.get("accept-language")
|
accept_language = request.headers.get("accept-language")
|
||||||
lang = _resolve_lang(accept_language)
|
if accept_language:
|
||||||
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
|
lang = _resolve_lang(accept_language)
|
||||||
title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama|隱私權政策"
|
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
|
||||||
page = render_as_simple_html(title=title, content=content)
|
title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama|隱私權政策"
|
||||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
html_lang = "en" if resolved == "en" else "zh-Hant"
|
||||||
|
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
|
||||||
|
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
|
||||||
|
|
||||||
|
content = _build_bilingual_content(PRIVACY_POLICY_MD)
|
||||||
|
title = "Dear Mama | Privacy Policy / 隱私權政策"
|
||||||
|
page = render_as_simple_html(title=title, content=content, html_lang="en")
|
||||||
|
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/terms", response_class=HTMLResponse)
|
@router.get("/terms", response_class=HTMLResponse)
|
||||||
@@ -123,9 +206,37 @@ async def get_terms_of_use(request: Request) -> HTMLResponse:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
accept_language = request.headers.get("accept-language")
|
accept_language = request.headers.get("accept-language")
|
||||||
lang = _resolve_lang(accept_language)
|
if accept_language:
|
||||||
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
|
lang = _resolve_lang(accept_language)
|
||||||
title = "Dear Mama – Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
|
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
|
||||||
page = render_as_simple_html(title=title, content=content)
|
title = "Dear Mama – Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
|
||||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
html_lang = "en" if resolved == "en" else "zh-Hant"
|
||||||
|
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
|
||||||
|
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
|
||||||
|
|
||||||
|
content = _build_bilingual_content(TERMS_OF_USE_MD)
|
||||||
|
title = "Dear Mama – Terms of Use / 使用條款"
|
||||||
|
page = render_as_simple_html(title=title, content=content, html_lang="en")
|
||||||
|
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support", response_class=HTMLResponse)
|
||||||
|
async def get_support_page(request: Request) -> HTMLResponse:
|
||||||
|
"""
|
||||||
|
技术支持页面(用于 App 审核的可访问 URL)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
accept_language = request.headers.get("accept-language")
|
||||||
|
if 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|技術支援"
|
||||||
|
html_lang = "en" if resolved == "en" else "zh-Hant"
|
||||||
|
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
|
||||||
|
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
|
||||||
|
|
||||||
|
content = _build_bilingual_content(SUPPORT_MD)
|
||||||
|
title = "Dear Mama | Technical Support / 技術支援"
|
||||||
|
page = render_as_simple_html(title=title, content=content, html_lang="en")
|
||||||
|
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,32 @@ async def register(req: PushRegisterRequest, db: AsyncSession = Depends(get_db))
|
|||||||
token.is_active = True
|
token.is_active = True
|
||||||
token.last_seen_at = _ensure_utc(now)
|
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()
|
await db.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -187,8 +213,11 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend
|
|||||||
else:
|
else:
|
||||||
pref.enabled = enabled
|
pref.enabled = enabled
|
||||||
pref.times_per_day = times
|
pref.times_per_day = times
|
||||||
pref.timezone = req.timezone
|
# 注意:只在客户端显式传入时覆盖,避免把已保存的 timezone/locale 清空导致排程回退到 UTC
|
||||||
pref.locale = req.locale
|
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:
|
if req.user_profile is not None:
|
||||||
pref.user_profile_json = req.user_profile.model_dump(mode="json")
|
pref.user_profile_json = req.user_profile.model_dump(mode="json")
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base
|
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")
|
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="失败原因(可选)")
|
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="创建时间")
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), comment="创建时间")
|
||||||
|
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ def choose_content_by_lang(text: str, lang: ResolvedLang) -> tuple[str, Resolved
|
|||||||
return en, "en"
|
return en, "en"
|
||||||
|
|
||||||
|
|
||||||
def render_as_simple_html(title: str, content: str) -> str:
|
def render_as_simple_html(title: str, content: str, html_lang: str = "en") -> str:
|
||||||
"""
|
"""
|
||||||
将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。
|
将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。
|
||||||
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
|
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
|
||||||
@@ -273,8 +273,9 @@ def render_as_simple_html(title: str, content: str) -> str:
|
|||||||
|
|
||||||
safe_title = html.escape(title)
|
safe_title = html.escape(title)
|
||||||
safe_content = html.escape(content)
|
safe_content = html.escape(content)
|
||||||
|
safe_lang = html.escape(html_lang or "en")
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html lang="en">
|
<html lang="{safe_lang}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from celery import current_app, shared_task
|
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.core.config import get_settings
|
||||||
from app.db.models.push_preference import PushPreference
|
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:
|
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]:
|
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]:
|
def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
|
||||||
"""
|
"""
|
||||||
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动)。
|
将窗口均匀切分为 n 个区间,并在每段内取“中点 + 受限抖动”的时间点。
|
||||||
|
|
||||||
|
目的:
|
||||||
|
- 尽量均匀分布(避免相邻两条推送随机到非常接近的时间)
|
||||||
|
- 仍保留一定随机性,避免过于机械
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if n <= 0:
|
if n <= 0:
|
||||||
@@ -84,8 +89,15 @@ def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[dat
|
|||||||
if seg <= 0:
|
if seg <= 0:
|
||||||
out.append(seg_start)
|
out.append(seg_start)
|
||||||
continue
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -167,16 +179,25 @@ async def _generate_schedule_once(*, now_utc: datetime, max_users: int = 5000) -
|
|||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
# 投递 ETA 发送任务
|
# 投递 ETA 发送任务
|
||||||
current_app.send_task(
|
try:
|
||||||
"tasks.push.send_scheduled",
|
current_app.send_task(
|
||||||
kwargs={
|
"tasks.push.send_scheduled",
|
||||||
"client_user_id": pref.client_user_id,
|
kwargs={
|
||||||
"local_date": target.local_date.isoformat(),
|
"client_user_id": pref.client_user_id,
|
||||||
"slot_index": int(idx),
|
"local_date": target.local_date.isoformat(),
|
||||||
},
|
"slot_index": int(idx),
|
||||||
eta=dt_utc,
|
},
|
||||||
)
|
eta=dt_utc,
|
||||||
scheduled += 1
|
)
|
||||||
|
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}
|
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"}
|
return {"status": "noop", "reason": "no_log"}
|
||||||
if str(log.status) == "sent":
|
if str(log.status) == "sent":
|
||||||
return {"status": "noop", "reason": "already_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) 当前偏好检查(用户可能中途关闭/改次数)
|
# 2) 当前偏好检查(用户可能中途关闭/改次数)
|
||||||
qpref = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
|
qpref = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
|
||||||
@@ -235,71 +284,123 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": "failed", "reason": "no_active_token"}
|
return {"status": "failed", "reason": "no_active_token"}
|
||||||
|
|
||||||
# 4) 生成文案(复用推荐模块 push 场景)
|
|
||||||
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
|
|
||||||
title = _pick_title(reco_locale)
|
|
||||||
|
|
||||||
if pref.user_profile_json:
|
|
||||||
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
|
|
||||||
else:
|
|
||||||
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
|
|
||||||
user_profile = UserProfileV1_2.model_validate(
|
|
||||||
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 直接复用 reco 的 Celery 任务实现(同步函数)
|
|
||||||
from app.tasks.reco import generate as reco_generate
|
|
||||||
|
|
||||||
reco_payload = reco_generate(scene="push", user_profile=user_profile.model_dump(), k=1, locale=reco_locale)
|
|
||||||
body = ""
|
|
||||||
try:
|
try:
|
||||||
items = (reco_payload or {}).get("items") or []
|
# 4) 生成文案(复用推荐模块 push 场景)
|
||||||
if items and isinstance(items, list):
|
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
|
||||||
body = str(items[0].get("text") or "").strip()
|
title = _pick_title(reco_locale)
|
||||||
except Exception:
|
|
||||||
|
if pref.user_profile_json:
|
||||||
|
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
|
||||||
|
else:
|
||||||
|
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
|
||||||
|
user_profile = UserProfileV1_2.model_validate(
|
||||||
|
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关键:这里不能调用 tasks.reco.generate(内部会 asyncio.run),否则会嵌套事件循环崩溃。
|
||||||
|
from app.tasks.reco import run_reco_payload_async
|
||||||
|
|
||||||
|
# 去重:用户推送过的内容尽量不再推送
|
||||||
|
# 说明:
|
||||||
|
# - 依赖 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 = ""
|
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):
|
||||||
|
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:
|
if not body:
|
||||||
body = "给自己一句温柔的话。"
|
# tc 语言兜底文案使用繁体
|
||||||
|
body = "給自己一句溫柔的話。" if reco_locale == "tc" else "给自己一句温柔的话。"
|
||||||
|
|
||||||
# 5) 发送
|
# 5) 发送
|
||||||
try:
|
|
||||||
expo_res = await _send_expo_push(
|
expo_res = await _send_expo_push(
|
||||||
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={"client_user_id": client_user_id, "scene": "push"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 6) 解析 Expo 回执,必要时停用 token
|
||||||
|
try:
|
||||||
|
data_list = (expo_res or {}).get("data") or []
|
||||||
|
if data_list and isinstance(data_list, list):
|
||||||
|
first = data_list[0] or {}
|
||||||
|
if first.get("status") == "error":
|
||||||
|
details = first.get("details") or {}
|
||||||
|
err = str(details.get("error") or first.get("message") or "expo_error")
|
||||||
|
log.status = "failed"
|
||||||
|
log.error = err
|
||||||
|
if "DeviceNotRegistered" in err:
|
||||||
|
token.is_active = False
|
||||||
|
await session.commit()
|
||||||
|
return {"status": "failed", "expo": expo_res}
|
||||||
|
except Exception:
|
||||||
|
# 忽略解析异常,继续按成功处理
|
||||||
|
pass
|
||||||
|
|
||||||
|
log.status = "sent"
|
||||||
|
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
log.error = None
|
||||||
|
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:
|
except Exception as e:
|
||||||
|
# 兜底:任何未预期异常都不要让状态卡在 sending
|
||||||
log.status = "failed"
|
log.status = "failed"
|
||||||
log.error = f"send_failed:{type(e).__name__}"
|
log.error = f"unexpected:{type(e).__name__}"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": "failed", "error": str(e)}
|
return {"status": "failed", "error": str(e)}
|
||||||
|
|
||||||
# 6) 解析 Expo 回执,必要时停用 token
|
|
||||||
try:
|
|
||||||
data_list = (expo_res or {}).get("data") or []
|
|
||||||
if data_list and isinstance(data_list, list):
|
|
||||||
first = data_list[0] or {}
|
|
||||||
if first.get("status") == "error":
|
|
||||||
details = first.get("details") or {}
|
|
||||||
err = str(details.get("error") or first.get("message") or "expo_error")
|
|
||||||
log.status = "failed"
|
|
||||||
log.error = err
|
|
||||||
if "DeviceNotRegistered" in err:
|
|
||||||
token.is_active = False
|
|
||||||
await session.commit()
|
|
||||||
return {"status": "failed", "expo": expo_res}
|
|
||||||
except Exception:
|
|
||||||
# 忽略解析异常,继续按成功处理
|
|
||||||
pass
|
|
||||||
|
|
||||||
log.status = "sent"
|
|
||||||
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
||||||
log.error = None
|
|
||||||
await session.commit()
|
|
||||||
return {"status": "sent", "expo": expo_res}
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="tasks.push.send_scheduled")
|
@shared_task(name="tasks.push.send_scheduled")
|
||||||
def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]:
|
def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]:
|
||||||
@@ -310,3 +411,50 @@ def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) ->
|
|||||||
d = date.fromisoformat(str(local_date))
|
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)))
|
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(
|
def _run_reco_sync(
|
||||||
*,
|
*,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
|
|||||||
@@ -53,10 +53,19 @@ celery_app.conf.beat_schedule = {
|
|||||||
},
|
},
|
||||||
"push-generate-daily-schedule": {
|
"push-generate-daily-schedule": {
|
||||||
"task": "tasks.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},
|
"kwargs": {"max_users": 5000},
|
||||||
"options": {"queue": f"{prefix}:celery"},
|
"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 触发其内部对子模块的显式导入)
|
# 自动发现任务(约定:导入 app.tasks 触发其内部对子模块的显式导入)
|
||||||
|
|||||||
@@ -202,6 +202,9 @@
|
|||||||
- 后端新增内置协议内容页:
|
- 后端新增内置协议内容页:
|
||||||
- `GET /v1/legal/privacy`
|
- `GET /v1/legal/privacy`
|
||||||
- `GET /v1/legal/terms`
|
- `GET /v1/legal/terms`
|
||||||
|
- `GET /v1/legal/support`(技术支持页面,提供审核可用的公开支持信息)
|
||||||
|
- 协议展示策略调整:`/v1/legal/privacy`、`/v1/legal/terms`、`/v1/legal/support` 在携带 `Accept-Language` 时按语言单语展示(EN/TC),缺省时展示 EN + TC(EN 在前、TC 在后)
|
||||||
|
- 协议页面语义修复:HTML 根节点 `lang` 属性不再写死,改为随页面实际语言输出(单语 en/zh-Hant;双语默认 en)
|
||||||
- 客户端新增协议接口封装 `client/src/services/legalApi.ts`
|
- 客户端新增协议接口封装 `client/src/services/legalApi.ts`
|
||||||
- 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`(baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入
|
- 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`(baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入
|
||||||
- 客户端接入两处入口:`app/(splash)/splash.tsx`、`components/home/ProfileModal.tsx`
|
- 客户端接入两处入口:`app/(splash)/splash.tsx`、`components/home/ProfileModal.tsx`
|
||||||
|
|||||||