"""消息通知中心 数据仓库(落库版,查/写 `notification` 表)。 沿用原 notification_mock 的同名函数(list_notifications / unread_count / mark_read / insert_sample),由内存 mock 迁到落库,**API 契约不变**。 - 读:按 user 隔离、sent_at 倒序;未读数 / 标记已读同口径。 - 写:`create_notification` 是落库统一入口。**业务事件请走 services/notification_events** (站内消息 + 厂商 push 一起下发,已接入提现回执/反馈审核/爆料通过/好友下单); `build_sample_card` / `insert_sample` 按类型造样例内容,供 `/api/v1/push/test` 的 createNotification 做「push → 站内已读联动」联调。 排序规则:全列表按 sent_at 倒序(最新在前;同秒再按 id 倒序稳定化),不分组。 """ from __future__ import annotations from datetime import datetime, timedelta, timezone from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core import notification_catalog as catalog from app.models.notification import Notification # 北京时间:sent_at 统一带 +08:00 下发,前端直接按本地时区渲染「今天/昨天/M月D日」。 _CST = timezone(timedelta(hours=8)) def cash_yuan(cents: int | None) -> str | None: """分 → 保留两位小数的元字符串(PRD §3:现金/提现金额保留两位小数)。""" if cents is None: return None return f"{cents // 100}.{cents % 100:02d}" def as_cst(dt: datetime) -> datetime: """把库里取出的时间归一到北京时间(+08:00)再下发,保证接口 sentAt 恒带 +08:00。 SQLite 的 DateTime 不存时区,取出为 naive(存的就是写入时的 CST 墙上时间)→ 直接贴 +08:00; PostgreSQL 的 timestamptz 取出为 aware(通常 UTC)→ 转到 +08:00。两端下发口径一致。 """ if dt.tzinfo is None: return dt.replace(tzinfo=_CST) return dt.astimezone(_CST) def _fmt_time(dt: datetime) -> str: """信息行里「到账时间」等 value 的展示格式。""" return dt.strftime("%Y-%m-%d %H:%M") # --------------------------------------------------------------------------- # 读:列表 / 未读数 / 标记已读 # --------------------------------------------------------------------------- def _unread_count(db: Session, user_id: int) -> int: return int( db.execute( select(func.count()) .select_from(Notification) .where(Notification.user_id == user_id, Notification.is_read.is_(False)) ).scalar_one() ) def list_notifications( db: Session, user_id: int, *, page: int, page_size: int ) -> tuple[list[Notification], int, int]: """分页取通知列表。返回 (当前页条目, 总条数, 未读条数)。""" total = int( db.execute( select(func.count()) .select_from(Notification) .where(Notification.user_id == user_id) ).scalar_one() ) unread = _unread_count(db, user_id) rows = ( db.execute( select(Notification) .where(Notification.user_id == user_id) .order_by(Notification.sent_at.desc(), Notification.id.desc()) .offset((page - 1) * page_size) .limit(page_size) ) .scalars() .all() ) return list(rows), total, unread def unread_count(db: Session, user_id: int) -> int: """未读总数(首页铃铛角标)。""" return _unread_count(db, user_id) def mark_read( db: Session, user_id: int, *, ids: list[int] | None = None, mark_all: bool = False ) -> tuple[int, int]: """标记已读。mark_all=True 全量清零,否则按 ids 逐条置读(不存在的 id 忽略,幂等)。 返回 (本次实际由未读→已读的条数, 剩余未读数)。 """ if not mark_all: wanted = set(ids or []) if not wanted: return 0, _unread_count(db, user_id) stmt = select(Notification).where( Notification.user_id == user_id, Notification.is_read.is_(False) ) if not mark_all: stmt = stmt.where(Notification.id.in_(wanted)) now = datetime.now(timezone.utc) marked = 0 for n in db.execute(stmt).scalars().all(): n.is_read = True n.read_at = now marked += 1 db.commit() return marked, _unread_count(db, user_id) # --------------------------------------------------------------------------- # 写:业务下发入口 # --------------------------------------------------------------------------- def create_notification( db: Session, *, user_id: int, type_key: str, coins: int | None = None, cash_cents: int | None = None, info_rows: list[dict[str, str]] | None = None, extra: dict[str, str] | None = None, sent_at: datetime | None = None, dedup_key: str | None = None, ) -> Notification: """下发一条站内消息(业务事件统一入口)。type_key 必须是 catalog 的 13 类之一。 dedup_key 非空时受部分唯一索引约束(同 user+type+dedup_key 未读期间仅一条); 需要「同批次/同权限只保留一条未读」的调用方,应捕获 IntegrityError 或先查已存在的未读再决定 更新 sent_at,而非重复插入(见 models/notification 的 uq_notification_user_type_dedup)。 """ catalog.get_type(type_key) # 校验类型合法(未知类型抛 UnknownNotificationType) row = Notification( user_id=user_id, type=type_key, coins=coins, cash_cents=cash_cents, info_rows=info_rows or [], extra=extra or {}, sent_at=sent_at or datetime.now(_CST), dedup_key=dedup_key, ) db.add(row) db.commit() db.refresh(row) return row # --------------------------------------------------------------------------- # 样例内容(供 /push/test createNotification 联调;文案对齐 PRD §3) # --------------------------------------------------------------------------- def _card_reward_expiring(sent_at: datetime, coins: int = 86, cash: int = 1280, days: int = 3) -> dict: return { "coins": coins, "cash_cents": cash, "info_rows": [ { "label": "过期说明", "value": f"您有{coins}金币和{cash_yuan(cash)}元现金即将失效," "完成一次一键领券或一键比价即可激活收益", }, {"label": "过期时间", "value": f"{days}天后失效"}, ], # batchId:同一批次激活成功后不再重复推送(PRD §2 激活逻辑) "extra": {"batchId": f"batch_{sent_at:%Y%m%d}"}, } def _card_reward_expired(sent_at: datetime, coins: int = 35, cash: int = 60) -> dict: return { "coins": coins, "cash_cents": cash, "info_rows": [ { "label": "过期说明", "value": f"您的{coins}金币和{cash_yuan(cash)}元现金已失效," "完成一次一键领券或一键比价可赚取新收益", }, {"label": "过期时间", "value": f"已过期 {sent_at.month}月{sent_at.day}日失效"}, ], "extra": {}, # 点击跳赚钱页(tab),无需参数 } def _card_withdraw_success(sent_at: datetime, cash: int = 50) -> dict: return { "cash_cents": cash, "info_rows": [ {"label": "到账账户", "value": "微信钱包"}, {"label": "到账时间", "value": _fmt_time(sent_at)}, ], "extra": {}, # 无跳转,仅消红点 } def _card_withdraw_failed(sent_at: datetime, cash: int = 350, reason: str = "微信零钱未实名") -> dict: return { "cash_cents": cash, "info_rows": [ {"label": "失败原因", "value": reason}, {"label": "退回说明", "value": "款项已原路退回现金余额"}, ], "extra": {"withdrawId": "88001"}, # 点击跳提现页 } def _card_permission(permission: str) -> dict: # permission ∈ accessibility(无障碍)/ battery(省电策略)/ autostart(自启动)/ overlay(悬浮窗) # 客户端点击时按此 key 实时检测该权限并弹对应开启弹窗(PRD §2 权限逻辑)。 return { "info_rows": [ {"label": "说明文案", "value": "未开启将导致核心功能不可用,请尽快开启"}, ], "extra": {"permission": permission}, } def _card_feedback_reply(feedback_id: str) -> dict: return { "info_rows": [ {"label": "说明文案", "value": "快去看看官方给您的回复吧~"}, ], "extra": {"feedbackId": feedback_id}, # 跳反馈历史页并滚动高亮该条(PRD §2) } def _card_feedback_reward(sent_at: datetime, coins: int = 300, reply: str = "感谢反馈,您说的问题已经修复上线,送您的金币请查收~") -> dict: return { "coins": coins, "info_rows": [ {"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"}, {"label": "官方留言", "value": reply}, # PRD §3:官方留言必填(发奖励必带留言) {"label": "到账时间", "value": _fmt_time(sent_at)}, ], "extra": {"feedbackId": "3002"}, } def _card_report_approved(sent_at: datetime, coins: int = 1000, store: str = "蜀大侠火锅") -> dict: return { "coins": coins, "info_rows": [ {"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"}, {"label": "到账时间", "value": _fmt_time(sent_at)}, ], "extra": {"reportId": "5001"}, # 跳爆料记录页并滚动高亮该条 } def _card_invite_order_reward(sent_at: datetime, cash: int = 200, nickname: str = "柚子") -> dict: return { "cash_cents": cash, "info_rows": [ {"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"}, {"label": "到账时间", "value": _fmt_time(sent_at)}, ], "extra": {"inviteeNickname": nickname}, # 跳邀请页(welfare/invite.html?from=notifications) } def _card_invite_remind(nickname: str = "阿泽") -> dict: return { "info_rows": [ { "label": "说明文案", "value": f"好友「{nickname}」已注册,还没完成比价下单,提醒TA完成后你可得2元现金", }, ], # scrollTo=remind:跳邀请页并自动滚动到底部「提醒好友」模块(PRD §2 #13) "extra": {"inviteeNickname": nickname, "scrollTo": "remind"}, } def build_sample_card(type_key: str, sent_at: datetime | None = None) -> dict: """按类型生成一份样例卡片内容({coins?, cash_cents?, info_rows, extra}),/push/test 联调用。""" catalog.get_type(type_key) # 校验 type 合法 now = sent_at or datetime.now(_CST) builders = { "reward_expiring": lambda: _card_reward_expiring(now), "reward_expired": lambda: _card_reward_expired(now), "withdraw_success": lambda: _card_withdraw_success(now), "withdraw_failed": lambda: _card_withdraw_failed(now), "perm_accessibility": lambda: _card_permission("accessibility"), "perm_battery": lambda: _card_permission("battery"), "perm_autostart": lambda: _card_permission("autostart"), "perm_overlay": lambda: _card_permission("overlay"), "feedback_reply": lambda: _card_feedback_reply("3001"), "feedback_reward": lambda: _card_feedback_reward(now), "report_approved": lambda: _card_report_approved(now), "invite_order_reward": lambda: _card_invite_order_reward(now), "invite_remind": lambda: _card_invite_remind(), } return builders[type_key]() def insert_sample(db: Session, user_id: int, type_key: str) -> Notification: """插入一条该类型的样例未读通知并落库(/push/test createNotification 联调:push extras 带上 它的 id,客户端点击 push 后调 POST /notifications/read {ids:[id]} 即闭环验证已读联动)。""" return create_notification(db, user_id=user_id, type_key=type_key, **build_sample_card(type_key))