diff --git a/server/alembic/versions/0003_add_push_send_log_payload.py b/server/alembic/versions/0003_add_push_send_log_payload.py new file mode 100644 index 0000000..2c8f747 --- /dev/null +++ b/server/alembic/versions/0003_add_push_send_log_payload.py @@ -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") + diff --git a/server/app/api/v1/push.py b/server/app/api/v1/push.py index 266caf2..3a14821 100644 --- a/server/app/api/v1/push.py +++ b/server/app/api/v1/push.py @@ -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,8 +213,11 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend else: pref.enabled = enabled pref.times_per_day = times - pref.timezone = req.timezone - pref.locale = req.locale + # 注意:只在客户端显式传入时覆盖,避免把已保存的 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") diff --git a/server/app/db/models/push_send_log.py b/server/app/db/models/push_send_log.py index b340ba7..3449343 100644 --- a/server/app/db/models/push_send_log.py +++ b/server/app/db/models/push_send_log.py @@ -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="创建时间") diff --git a/server/app/tasks/push.py b/server/app/tasks/push.py index a9872dd..6d1f69f 100644 --- a/server/app/tasks/push.py +++ b/server/app/tasks/push.py @@ -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]: @@ -288,17 +289,61 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index: # 关键:这里不能调用 tasks.reco.generate(内部会 asyncio.run),否则会嵌套事件循环崩溃。 from app.tasks.reco import run_reco_payload_async - body = "" + # 去重:同一用户同一天尽量不重复推送相同 content + used_ids: list[int] = [] try: - reco_payload = await run_reco_payload_async(scene="push", user_profile=user_profile, k=1, locale=reco_locale) + qused = ( + select(PushSendLog.content_id) + .where( + PushSendLog.client_user_id == client_user_id, + PushSendLog.local_date == local_date, + PushSendLog.content_id.is_not(None), + PushSendLog.id != log.id, + ) + .order_by(PushSendLog.slot_index.asc()) + ) + 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) 发送 expo_res = await _send_expo_push( @@ -329,6 +374,9 @@ 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: diff --git a/server/celerybeat-schedule b/server/celerybeat-schedule index 782ef75..df15f25 100644 Binary files a/server/celerybeat-schedule and b/server/celerybeat-schedule differ diff --git a/server/celerybeat-schedule-shm b/server/celerybeat-schedule-shm index a285d7b..9fb6ee4 100644 Binary files a/server/celerybeat-schedule-shm and b/server/celerybeat-schedule-shm differ diff --git a/server/celerybeat-schedule-wal b/server/celerybeat-schedule-wal index 967e4cc..85a2a3f 100644 Binary files a/server/celerybeat-schedule-wal and b/server/celerybeat-schedule-wal differ