96 lines
4.3 KiB
Python
96 lines
4.3 KiB
Python
"""消息通知中心:站内消息表(一行 = 一条下发给某用户的站内消息)。
|
|
|
|
13 类通知的**静态定义**(分类 / 版式 / 标题 / 操作行 / push 模板)在
|
|
`app/core/notification_catalog.py`,是代码常量,**不入库**;本表只存**每条消息的动态部分**
|
|
(与接口 NotificationItem 的动态字段一一对应):type + 金额 + 信息行 + extra + 已读态 + 时间。
|
|
category / card_style / title / action_text 都由 `type` 经 catalog 派生,不冗余存库。
|
|
|
|
- 写:`repositories/notification.create_notification`(业务事件下发站内消息的统一入口)。
|
|
- 读:`api/v1/notifications.py`(列表 / 未读数 / 标记已读),均按 user 隔离、sent_at 倒序。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import (
|
|
JSON,
|
|
Boolean,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
func,
|
|
text,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from app.db.base import Base
|
|
|
|
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 comparison_record.raw_payload 等)。
|
|
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
|
|
|
|
|
class Notification(Base):
|
|
__tablename__ = "notification"
|
|
__table_args__ = (
|
|
# 列表分页:按用户取 + sent_at 倒序(核心查询,覆盖 user_id 前缀查找,故不再单独索引 user_id)
|
|
Index("ix_notification_user_sent", "user_id", "sent_at"),
|
|
# 铃铛角标:count where user_id=? and is_read=false —— 部分索引只覆盖未读行
|
|
Index(
|
|
"ix_notification_user_unread",
|
|
"user_id",
|
|
sqlite_where=text("is_read = 0"),
|
|
postgresql_where=text("is_read = false"),
|
|
),
|
|
# 去重/合并:同一 (user, type, dedup_key) 未读期间只允许一条(perm_* 权限异常、
|
|
# reward_expiring 同批次即用它);消息一旦已读即离开索引,之后可再生成新的未读消息。
|
|
Index(
|
|
"uq_notification_user_type_dedup",
|
|
"user_id",
|
|
"type",
|
|
"dedup_key",
|
|
unique=True,
|
|
sqlite_where=text("dedup_key IS NOT NULL AND is_read = 0"),
|
|
postgresql_where=text("dedup_key IS NOT NULL AND is_read = false"),
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False)
|
|
# 13 类之一(catalog.TYPES 的 key);category/card_style/title/action_text 由它派生,不入库
|
|
type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
# 金币数(dual_amount / coin_reward 卡);其余类型 None
|
|
coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
# 现金,单位【分】(dual_amount / withdraw / friend_cash 卡);其余 None
|
|
cash_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
# 信息行 [{label, value}](已渲染好文案,前端逐行展示)
|
|
info_rows: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
|
# 点击跳转/联动参数(feedbackId / withdrawId / permission / inviteeNickname / batchId …)
|
|
extra: Mapped[dict] = mapped_column(_JSON, nullable=False, default=dict)
|
|
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
# 置读时刻(未读时为 None;埋点/分析用)
|
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
# 去重键(可空):perm_*→permission、reward_expiring→batchId 等;配合部分唯一索引防重复未读
|
|
dedup_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# 下发/业务时间;列表排序与展示都用它(带 +08:00 下发)
|
|
sent_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
onupdate=func.now(),
|
|
nullable=False,
|
|
)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover
|
|
return (
|
|
f"<Notification id={self.id} user_id={self.user_id} "
|
|
f"type={self.type} read={self.is_read}>"
|
|
)
|