fix:每日推荐修复

This commit is contained in:
吕新雨
2026-02-12 13:54:34 +08:00
parent eef5210c99
commit 62fcc4bfce
7 changed files with 121 additions and 8 deletions

View 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")

View File

@@ -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")

View File

@@ -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="创建时间")

View File

@@ -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]:
@@ -288,17 +289,61 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
# 关键:这里不能调用 tasks.reco.generate内部会 asyncio.run否则会嵌套事件循环崩溃。 # 关键:这里不能调用 tasks.reco.generate内部会 asyncio.run否则会嵌套事件循环崩溃。
from app.tasks.reco import run_reco_payload_async from app.tasks.reco import run_reco_payload_async
body = "" # 去重:同一用户同一天尽量不重复推送相同 content
used_ids: list[int] = []
try: 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 [] items = (reco_payload or {}).get("items") or []
if items and isinstance(items, list): 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: except Exception:
body = "" body = ""
if not body: if not body:
body = "给自己一句温柔的话。" # tc 语言兜底文案使用繁体
body = "給自己一句溫柔的話。" if reco_locale == "tc" else "给自己一句温柔的话。"
# 5) 发送 # 5) 发送
expo_res = await _send_expo_push( 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.status = "sent"
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None) log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
log.error = None log.error = None
log.title = title
log.body = body
log.content_id = picked_content_id
await session.commit() await session.commit()
return {"status": "sent", "expo": expo_res} return {"status": "sent", "expo": expo_res}
except Exception as e: except Exception as e:

Binary file not shown.

Binary file not shown.

Binary file not shown.