Files
shaguabijia-app-server/app/repositories/notification_mock.py
T
左辰勇 a8ac5dc0c7 消息通知中心 3 接口(mock 阶段) + 五厂商推送(补华为/通用发送/测试三件套)
- 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>
2026-07-14 18:57:04 +08:00

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)