a8ac5dc0c7
- GET /api/v1/notifications(分页,时间倒序不分组)/unread-count /read(ids|all),
对外 camelCase(PRD 前端契约);数据为内存 mock(13 类型全覆盖,重启复位),
接真实数据只换 repositories/notification_mock.py
- vendor_push 补华为 Push Kit(OAuth+messages:send),抽通用 send_notification
(任意文案+extras+mock 模式),send_accessibility_disabled 改薄封装行为不变
- 新增 /api/v1/push/{vendors,templates,test}:凭据状态/13 类 PRD 文案模板/测试发送
(默认 mock,mock=false 真发,可联动插站内 mock 消息闭环验证已读)
- .env.example 补 HUAWEI_* 键;docs/api 新增 notifications.md + push-vendor-test.md
- alembic merge 1a924c274fce 收拢 direct_vendor_push_fields/feedback_type_reply
双 head,恢复 upgrade head 可用
- tests: test_notifications + test_push_center 共 35 例
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
329 lines
14 KiB
Python
329 lines
14 KiB
Python
"""消息通知中心 mock 数据仓库(内存版,不落库)。
|
|
|
|
⚠️ 虚拟数据阶段专用:
|
|
- 每个用户**首次访问时**生成一套固定样例(覆盖全部 13 种类型 + 「今天/昨天/当年/跨年」
|
|
四种时间显示分支 + 已读/未读混合),供前端联调列表渲染、角标、已读消除全流程。
|
|
- 已读状态维护在进程内存 → **重启服务即复位**;多 worker 部署时各 worker 各一份
|
|
(联调期单 worker 跑即可,与 core/ratelimit.py 同款局限)。
|
|
- 后续接真实数据:建 notification 表 + 本文件同名函数改为查库,API 层无需改动。
|
|
|
|
排序规则:**全列表按时间倒序**(最新在前),不做分类分组(需求方 2026-07-14 确认
|
|
PRD §1 的"按分类分组"写错了,已取消);分页作用在排序后的整表上。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import itertools
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from app.core import notification_catalog as catalog
|
|
|
|
# 北京时间:sentAt 统一带 +08:00 偏移下发,前端直接按本地时区渲染「今天/昨天/M月D日」。
|
|
_CST = timezone(timedelta(hours=8))
|
|
|
|
# mock id 从 90001 起步,一眼可辨"这是虚拟数据"(真实表主键自增不会从这里开始)。
|
|
_id_counter = itertools.count(90001)
|
|
_lock = threading.Lock()
|
|
# user_id → 该用户的 mock 通知列表(生成后仅 is_read 会变化)
|
|
_stores: dict[int, list[MockNotification]] = {}
|
|
|
|
|
|
@dataclass
|
|
class MockNotification:
|
|
"""一条 mock 通知的动态部分;静态部分(标题/分类/版式/操作行)看 catalog.TYPES。"""
|
|
|
|
id: int
|
|
type_key: str
|
|
sent_at: datetime
|
|
is_read: bool
|
|
coins: int | None = None # 金币数(整数;dual_amount / coin_reward 卡)
|
|
cash_cents: int | None = None # 现金金额,单位分(dual_amount / withdraw / friend_cash 卡)
|
|
info_rows: list[dict[str, str]] = field(default_factory=list) # [{label, value}]
|
|
extra: dict[str, str] = field(default_factory=dict) # 点击跳转所需业务参数
|
|
|
|
|
|
def cash_yuan(cents: int | None) -> str | None:
|
|
"""分 → 保留两位小数的元字符串(PRD §3:现金/提现金额保留两位小数)。"""
|
|
if cents is None:
|
|
return None
|
|
return f"{cents // 100}.{cents % 100:02d}"
|
|
|
|
|
|
def _fmt_time(dt: datetime) -> str:
|
|
"""信息行里「到账时间」等 value 的展示格式。"""
|
|
return dt.strftime("%Y-%m-%d %H:%M")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 各类型的样例卡片内容(数值 + 信息行 + extra),文案对齐 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(type_key: str, sent_at: datetime | None = None) -> MockNotification:
|
|
"""按类型生成一条默认样例(push 测试联动等场景插入新 mock 通知用)。"""
|
|
catalog.get_type(type_key) # 校验 type 合法
|
|
now = sent_at or datetime.now(_CST)
|
|
default_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 MockNotification(
|
|
id=next(_id_counter),
|
|
type_key=type_key,
|
|
sent_at=now,
|
|
is_read=False,
|
|
**default_builders[type_key](),
|
|
)
|
|
|
|
|
|
def _seed_user(user_id: int) -> list[MockNotification]:
|
|
"""给一个用户生成整套 mock 样例:13 类型全覆盖 + 时间显示四分支 + 已读/未读混合。"""
|
|
now = datetime.now(_CST)
|
|
today = now - timedelta(hours=2) # 今天
|
|
today_recent = now - timedelta(minutes=10) # 今天(更近,验证组内时间倒序)
|
|
yesterday = now - timedelta(days=1) # 昨天
|
|
this_year = now - timedelta(days=40) # 当年 → 「M月D日」
|
|
last_year = now - timedelta(days=370) # 跨年 → 「YYYY年M月D日」
|
|
|
|
def one(type_key: str, sent_at: datetime, card: dict, *, read: bool = False) -> MockNotification:
|
|
return MockNotification(
|
|
id=next(_id_counter), type_key=type_key, sent_at=sent_at, is_read=read, **card
|
|
)
|
|
|
|
return [
|
|
# ---- 提现助手(4 类 + 2 条补充,验证组内倒序与跨年展示)----
|
|
one("reward_expiring", today, _card_reward_expiring(today)),
|
|
one("reward_expired", yesterday, _card_reward_expired(yesterday)),
|
|
one("withdraw_success", today_recent, _card_withdraw_success(today_recent)),
|
|
one("withdraw_failed", yesterday, _card_withdraw_failed(yesterday)),
|
|
one("withdraw_success", now - timedelta(days=3),
|
|
_card_withdraw_success(now - timedelta(days=3), cash=3000)),
|
|
one("withdraw_success", last_year, _card_withdraw_success(last_year, cash=120), read=True),
|
|
# ---- 系统通知(权限异常 ×4;同一权限未读期间只更新时间不新增,故各一条)----
|
|
one("perm_accessibility", now - timedelta(hours=1), _card_permission("accessibility")),
|
|
one("perm_battery", now - timedelta(days=3), _card_permission("battery"), read=True),
|
|
one("perm_autostart", now - timedelta(days=5), _card_permission("autostart")),
|
|
one("perm_overlay", now - timedelta(days=6), _card_permission("overlay"), read=True),
|
|
# ---- 我的反馈 ----
|
|
one("feedback_reply", now - timedelta(hours=4), _card_feedback_reply("3001")),
|
|
one("feedback_reward", yesterday, _card_feedback_reward(yesterday)),
|
|
one("feedback_reply", now - timedelta(days=60), _card_feedback_reply("2001"), read=True),
|
|
# ---- 我的爆料 ----
|
|
one("report_approved", this_year, _card_report_approved(this_year)),
|
|
# ---- 好友邀请 ----
|
|
one("invite_order_reward", today_recent, _card_invite_order_reward(today_recent)),
|
|
one("invite_remind", now - timedelta(days=2), _card_invite_remind()),
|
|
]
|
|
|
|
|
|
def _get_store(user_id: int) -> list[MockNotification]:
|
|
"""取该用户的 mock 列表,首次访问时生成。调用方须持有 _lock。"""
|
|
store = _stores.get(user_id)
|
|
if store is None:
|
|
store = _seed_user(user_id)
|
|
_stores[user_id] = store
|
|
return store
|
|
|
|
|
|
def _sorted(store: list[MockNotification]) -> list[MockNotification]:
|
|
"""排序:全列表时间倒序(同秒再按 id 倒序稳定化)。不分组(PRD 原文写错,已取消)。"""
|
|
return sorted(store, key=lambda n: (-n.sent_at.timestamp(), -n.id))
|
|
|
|
|
|
def list_notifications(
|
|
user_id: int, *, page: int, page_size: int
|
|
) -> tuple[list[MockNotification], int, int]:
|
|
"""分页取通知列表。返回 (当前页条目, 总条数, 未读条数)。"""
|
|
with _lock:
|
|
store = _get_store(user_id)
|
|
ordered = _sorted(store)
|
|
total = len(ordered)
|
|
unread = sum(1 for n in store if not n.is_read)
|
|
start = (page - 1) * page_size
|
|
return ordered[start : start + page_size], total, unread
|
|
|
|
|
|
def unread_count(user_id: int) -> int:
|
|
"""未读总数(首页铃铛角标)。"""
|
|
with _lock:
|
|
return sum(1 for n in _get_store(user_id) if not n.is_read)
|
|
|
|
|
|
def mark_read(
|
|
user_id: int, *, ids: list[int] | None = None, mark_all: bool = False
|
|
) -> tuple[int, int]:
|
|
"""标记已读。mark_all=True 全量清零,否则按 ids 逐条置读(不存在的 id 忽略,幂等)。
|
|
|
|
返回 (本次实际由未读→已读的条数, 剩余未读数)。
|
|
"""
|
|
with _lock:
|
|
store = _get_store(user_id)
|
|
marked = 0
|
|
if mark_all:
|
|
for n in store:
|
|
if not n.is_read:
|
|
n.is_read = True
|
|
marked += 1
|
|
else:
|
|
wanted = set(ids or [])
|
|
for n in store:
|
|
if n.id in wanted and not n.is_read:
|
|
n.is_read = True
|
|
marked += 1
|
|
unread = sum(1 for n in store if not n.is_read)
|
|
return marked, unread
|
|
|
|
|
|
def insert_sample(user_id: int, type_key: str) -> MockNotification:
|
|
"""插入一条该类型的新 mock 通知(未读)。push 测试联动用:push extras 带上它的 id,
|
|
客户端点击 push 后调 POST /notifications/read {ids:[id]} 即可闭环验证已读联动。"""
|
|
with _lock:
|
|
store = _get_store(user_id)
|
|
item = build_sample(type_key)
|
|
store.append(item)
|
|
return item
|
|
|
|
|
|
def reset_store(user_id: int | None = None) -> None:
|
|
"""清空 mock 状态(测试/联调用;user_id=None 清全部)。下次访问重新生成。"""
|
|
with _lock:
|
|
if user_id is None:
|
|
_stores.clear()
|
|
else:
|
|
_stores.pop(user_id, None)
|