0717c09721
改动:新增厂商推送配置、设备 push_vendor/push_token 字段、device push-test 接口、心跳超时厂商直推发送逻辑和对应测试。 验证:python -m pytest tests/test_device_push.py tests/test_auth.py tests/test_health.py 通过。 --------- Co-authored-by: guke <guke@wonderable.ai> Co-authored-by: 左辰勇 <exinglang@gmail.com> Co-authored-by: lowmaster-chen <1119780489@qq.com> Reviewed-on: #118 Co-authored-by: Ghost <> Co-committed-by: Ghost <>
319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""消息通知中心 数据仓库(落库版,查/写 `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))
|