From a8ac5dc0c7c669b4d8326c298464892e2d48b863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B7=A6=E8=BE=B0=E5=8B=87?= Date: Tue, 14 Jul 2026 18:57:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=80=9A=E7=9F=A5=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=203=20=E6=8E=A5=E5=8F=A3(mock=20=E9=98=B6=E6=AE=B5)?= =?UTF-8?q?=20+=20=E4=BA=94=E5=8E=82=E5=95=86=E6=8E=A8=E9=80=81(=E8=A1=A5?= =?UTF-8?q?=E5=8D=8E=E4=B8=BA/=E9=80=9A=E7=94=A8=E5=8F=91=E9=80=81/?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=B8=89=E4=BB=B6=E5=A5=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 9 +- ..._merge_direct_vendor_push_and_feedback_.py | 26 ++ app/api/v1/notifications.py | 120 ++++++ app/api/v1/push.py | 186 +++++++++ app/core/config.py | 9 + app/core/notification_catalog.py | 241 +++++++++++ app/integrations/vendor_push.py | 221 ++++++++-- app/main.py | 6 + app/repositories/notification_mock.py | 328 +++++++++++++++ app/schemas/notification.py | 128 ++++++ app/schemas/push.py | 99 +++++ docs/api/README.md | 12 +- docs/api/notifications.md | 121 ++++++ docs/api/push-vendor-test.md | 102 +++++ tests/test_notifications.py | 193 +++++++++ tests/test_push_center.py | 393 ++++++++++++++++++ 16 files changed, 2146 insertions(+), 48 deletions(-) create mode 100644 alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py create mode 100644 app/api/v1/notifications.py create mode 100644 app/api/v1/push.py create mode 100644 app/core/notification_catalog.py create mode 100644 app/repositories/notification_mock.py create mode 100644 app/schemas/notification.py create mode 100644 app/schemas/push.py create mode 100644 docs/api/notifications.md create mode 100644 docs/api/push-vendor-test.md create mode 100644 tests/test_notifications.py create mode 100644 tests/test_push_center.py diff --git a/.env.example b/.env.example index 96abab9..542cce5 100644 --- a/.env.example +++ b/.env.example @@ -27,8 +27,9 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify JG_REQUEST_TIMEOUT_SEC=15 -# ===== 厂商直推(无障碍保护存活告警)===== +# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)===== # 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。 +# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。 ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia PUSH_REQUEST_TIMEOUT_SEC=15 PUSH_TIME_TO_LIVE_SEC=86400 @@ -39,6 +40,12 @@ HONOR_PUSH_CLIENT_SECRET= HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage +# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret +HUAWEI_PUSH_APP_ID= +HUAWEI_PUSH_APP_SECRET= +HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token +HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send + VIVO_PUSH_APP_ID= VIVO_PUSH_APP_KEY= VIVO_PUSH_APP_SECRET= diff --git a/alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py b/alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py new file mode 100644 index 0000000..eb0144b --- /dev/null +++ b/alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py @@ -0,0 +1,26 @@ +"""merge direct_vendor_push and feedback_type_reply heads + +Revision ID: 1a924c274fce +Revises: direct_vendor_push_fields, feedback_type_reply +Create Date: 2026-07-14 18:53:02.856979 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1a924c274fce' +down_revision: Union[str, Sequence[str], None] = ('direct_vendor_push_fields', 'feedback_type_reply') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/api/v1/notifications.py b/app/api/v1/notifications.py new file mode 100644 index 0000000..515216f --- /dev/null +++ b/app/api/v1/notifications.py @@ -0,0 +1,120 @@ +"""消息通知中心 endpoint(PRD《消息通知中心》)。 + +路由前缀 `/api/v1/notifications`,需 Bearer 鉴权(消息按用户隔离)。 + GET / 消息列表(分页;全列表时间倒序,不分组——PRD 原文的分组已取消) + GET /unread-count 未读总数(首页铃铛角标) + POST /read 标记已读({ids:[...]} 单条/多条 或 {all:true} 全量清零) + +⚠️ 虚拟数据阶段:数据来自 repositories/notification_mock.py 内存 mock(重启复位, +每个用户一套覆盖全部 13 种类型的固定样例)。接真实数据时只换 repository 实现。 + +⚠️ 字段命名:本组接口对外为 **camelCase**(sentAt / isRead / pageSize…,PRD 前端契约), +详见 schemas/notification.py 顶部说明。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, Query + +from app.api.deps import CurrentUser +from app.core import notification_catalog as catalog +from app.repositories import notification_mock as mock_repo +from app.schemas.notification import ( + InfoRow, + MarkReadOut, + MarkReadRequest, + NotificationItem, + NotificationListOut, + UnreadCountOut, +) + +logger = logging.getLogger("shagua.notifications") + +router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"]) + + +def _to_item(n: mock_repo.MockNotification) -> NotificationItem: + """mock 记录 + 类型静态目录 → 接口出参。""" + ntype = catalog.get_type(n.type_key) + return NotificationItem( + id=n.id, + category=ntype.category, + category_label=catalog.category_label(ntype.category), + type=ntype.key, + card_style=ntype.card_style, + title=ntype.card_title, + coins=n.coins, + cash_cents=n.cash_cents, + cash_yuan=mock_repo.cash_yuan(n.cash_cents), + info_rows=[InfoRow(**row) for row in n.info_rows], + action_text=ntype.action_text, + extra=n.extra, + sent_at=n.sent_at, + is_read=n.is_read, + ) + + +@router.get("", response_model=NotificationListOut, summary="消息列表(分页)") +def list_notifications( + user: CurrentUser, + page: int = Query(default=1, ge=1, description="页码,1 起"), + page_size: int = Query( + default=20, ge=1, le=100, alias="pageSize", description="每页条数,默认 20,最大 100" + ), +) -> NotificationListOut: + """通知中心消息列表。 + + - 排序服务端已做好:**全列表按时间倒序**(最新在前,不做分类分组;PRD §1 的 + "按分类分组"为笔误,已与需求方确认取消),前端按返回顺序渲染即可。 + - 每条的字段构成与各版式说明见 NotificationItem schema。 + - 响应同时带 unreadCount,进页面时可顺手刷新角标。 + - 虚拟数据阶段:首次请求生成一套固定样例(13 类型全覆盖 + 今天/昨天/当年/跨年 + 四种时间 + 已读未读混合),已读状态在服务端内存维护,重启服务复位。 + """ + items, total, unread = mock_repo.list_notifications(user.id, page=page, page_size=page_size) + return NotificationListOut( + items=[_to_item(n) for n in items], + page=page, + page_size=page_size, + total=total, + has_more=page * page_size < total, + unread_count=unread, + ) + + +@router.get("/unread-count", response_model=UnreadCountOut, summary="未读总数(铃铛角标)") +def get_unread_count(user: CurrentUser) -> UnreadCountOut: + """首页铃铛角标数据源。刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时。 + + - count:精确未读条数; + - badgeText:直接可展示的角标文案——超过 99 返回 "99+",等于 0 返回 null(隐藏整个角标)。 + """ + count = mock_repo.unread_count(user.id) + badge = None if count == 0 else ("99+" if count > 99 else str(count)) + return UnreadCountOut(count=count, badge_text=badge) + + +@router.post("/read", response_model=MarkReadOut, summary="标记已读(单条/多条/全量)") +def mark_read(req: MarkReadRequest, user: CurrentUser) -> MarkReadOut: + """红点消除(PRD §4),两种调用模式: + + 1. `{"ids": [90001]}` —— 点击某张消息卡片(无论点击后是跳转/弹窗/无动作都算已读); + 用户点击 push 直达落地页时,客户端也用它把对应站内消息同步置读(push extras 里带 + notificationId); + 2. `{"all": true}` —— 进入通知中心自动清零(只是浏览列表就消红点,无需逐条点击)。 + + 幂等:不存在或已读的 id 忽略;重复调用 markedCount 为 0、不报错。 + 响应带 unreadCount(处理后剩余未读),可直接刷新铃铛角标。 + """ + if not req.all and not req.ids: + raise HTTPException(status_code=400, detail="ids 与 all 至少传一个:{ids:[...]} 或 {all:true}") + marked, unread = mock_repo.mark_read(user.id, ids=req.ids, mark_all=req.all) + logger.info( + "notifications read user_id=%d mode=%s marked=%d unread_left=%d", + user.id, + "all" if req.all else f"ids×{len(req.ids or [])}", + marked, + unread, + ) + return MarkReadOut(ok=True, marked_count=marked, unread_count=unread) diff --git a/app/api/v1/push.py b/app/api/v1/push.py new file mode 100644 index 0000000..cd0b76e --- /dev/null +++ b/app/api/v1/push.py @@ -0,0 +1,186 @@ +"""厂商推送 测试/联调 endpoint。 + +路由前缀 `/api/v1/push`,需 Bearer 鉴权。围绕「消息中心 13 类通知的厂商直推」提供三件套: + GET /vendors 5 个厂商(荣耀/华为/小米/OPPO/vivo)服务端凭据配置状态,缺哪些键一目了然 + GET /templates 13 种通知类型的 push 标题/正文模板 + PRD 示例渲染效果 + POST /test 测试发送:默认 mock(不真调厂商 API,回显渲染结果);mock=false 真发到手机 + +与 `/api/v1/device/push-test`(无障碍召回通道的延迟自测)互补:本组面向消息中心 13 类 +push 的文案/参数/厂商通道联调。真实业务触发(奖励过期、提现回执等)接入后走 +integrations.vendor_push.send_notification,与本测试端点同一条发送链路。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import CurrentUser, DbSession +from app.core import notification_catalog as catalog +from app.integrations import vendor_push +from app.repositories import device as device_repo +from app.repositories import notification_mock as mock_repo +from app.schemas.push import ( + PushTemplateOut, + PushTemplatesOut, + PushTestOut, + PushTestRequest, + PushVendorsOut, + PushVendorStatus, +) + +logger = logging.getLogger("shagua.push") + +router = APIRouter(prefix="/api/v1/push", tags=["push"]) + +# /vendors 的展示顺序(荣耀/华为/小米/OPPO/vivo) +_VENDOR_ORDER = ("honor", "huawei", "xiaomi", "oppo", "vivo") + +_GENERIC_TEST_TITLE = "傻瓜比价测试推送" +_GENERIC_TEST_BODY = "这是一条{label}通道的测试推送,收到说明服务端 → {label}厂商通道已打通。" + + +@router.get("/vendors", response_model=PushVendorsOut, summary="厂商推送配置状态") +def vendor_status(user: CurrentUser) -> PushVendorsOut: + """检查 5 个厂商的服务端推送凭据是否配齐(读 .env,不打厂商接口)。 + + missingKeys 列出的即还需要在 .env 里补的配置键;全空说明该厂商随时可真发。 + mock 测试(POST /test 默认模式)不依赖任何凭据。 + """ + return PushVendorsOut( + vendors=[ + PushVendorStatus( + vendor=v, + label=vendor_push.VENDOR_LABELS[v], + configured=not vendor_push.missing_settings(v), + missing_keys=vendor_push.missing_settings(v), + ) + for v in _VENDOR_ORDER + ] + ) + + +@router.get("/templates", response_model=PushTemplatesOut, summary="13 类通知的 push 模板预览") +def push_templates(user: CurrentUser) -> PushTemplatesOut: + """PRD §5 的 13 条 push 文案模板 + 用示例值渲染后的效果,联调对文案用。 + + 标题固定(≤11 字不带变量);正文里 {var} 为变量,POST /test 的 vars 字段可覆盖。 + """ + templates: list[PushTemplateOut] = [] + for key, ntype in catalog.TYPES.items(): + title, body_sample = catalog.render_push(key) + templates.append( + PushTemplateOut( + type=key, + category=ntype.category, + category_label=catalog.category_label(ntype.category), + card_style=ntype.card_style, + push_title=title, + push_body_sample=body_sample, + push_body_template=ntype.push_body_template, + variables=catalog.push_variable_names(key), + sample_vars=ntype.sample_vars, + ) + ) + return PushTemplatesOut(templates=templates) + + +@router.post("/test", response_model=PushTestOut, summary="测试发送厂商推送(默认 mock)") +def send_test_push(req: PushTestRequest, user: CurrentUser, db: DbSession) -> PushTestOut: + """向指定厂商 token(或本用户已注册设备)发一条测试 push。 + + - **mock=true(默认)**:不真调厂商 API——校验参数、渲染文案后原样返回,并在 + missingKeys 里提示真发前还缺哪些配置。虚拟数据阶段随便打,不会骚扰真机。 + - **mock=false**:真发。要求该厂商凭据已配置(缺则 400 报缺失键);厂商 API 报错回 502。 + 注意 vivo 未上架前是测试推送模式(VIVO_PUSH_MODE=1),目标手机要先在 vivo 后台加为测试设备。 + - **createNotification=true**:同时往消息中心 mock 列表插一条同类型未读通知并把 + notificationId 放进 push extras → 客户端点击 push 后调 POST /notifications/read + {ids:[notificationId]} 即可闭环验证 PRD §4 的 push 已读联动。 + """ + # ---- 1. 解析推送目标(vendor + token):直填优先,缺则按 deviceId 反查已注册设备 ---- + vendor_raw = req.vendor.strip() + push_token = req.push_token.strip() + if (not vendor_raw or not push_token) and req.device_id.strip(): + device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id.strip()) + if device is not None: + vendor_raw = vendor_raw or (device.push_vendor or "") + push_token = push_token or (device.push_token or "") + + vendor = vendor_push.normalize_vendor(vendor_raw) + if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"vendor 无效或无法从设备推断,支持: {', '.join(_VENDOR_ORDER)}", + ) + if not push_token: + # mock 模式给个占位 token,让「只想看看渲染结果」的调用免造数据;真发必须给真 token。 + if req.mock: + push_token = "mock-token" + else: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="push token 未知:请直传 pushToken,或先用该设备调 /api/v1/device/register 上报", + ) + + # ---- 2. 组装文案与 extras:直填 > type 模板 > 通用测试文案 ---- + extras: dict[str, str] = {} + notification_id: int | None = None + if req.type: + try: + title, body = catalog.render_push(req.type, req.vars or None) + except catalog.UnknownNotificationType as e: + raise HTTPException(status_code=400, detail=str(e)) from e + extras["type"] = req.type + if req.create_notification: + item = mock_repo.insert_sample(user.id, req.type) + notification_id = item.id + extras.update({str(k): str(v) for k, v in item.extra.items()}) + extras["notificationId"] = str(item.id) + else: + label = vendor_push.VENDOR_LABELS[vendor] + title = _GENERIC_TEST_TITLE + body = _GENERIC_TEST_BODY.format(label=label) + extras["type"] = "push_test" + if req.title.strip(): + title = req.title.strip() + if req.content.strip(): + body = req.content.strip() + + # ---- 3. 发送(mock / 真发) ---- + missing = vendor_push.missing_settings(vendor) + vendor_response = None + if req.mock: + vendor_push.send_notification( + vendor, push_token, title=title, body=body, extras=extras, mock=True + ) + else: + if missing: + raise HTTPException( + status_code=400, + detail=f"{vendor_push.VENDOR_LABELS[vendor]}推送凭据未配置,先在 .env 补上: " + f"{', '.join(missing)}", + ) + try: + vendor_response = vendor_push.send_notification( + vendor, push_token, title=title, body=body, extras=extras + ) + except vendor_push.VendorPushError as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"厂商推送失败: {e}" + ) from e + + logger.info( + "push test user_id=%d vendor=%s type=%s mock=%s notification_id=%s", + user.id, vendor, req.type or "generic", req.mock, notification_id, + ) + return PushTestOut( + ok=True, + mock=req.mock, + vendor=vendor, + title=title, + body=body, + extras=extras, + notification_id=notification_id, + missing_keys=missing, + vendor_response=vendor_response, + ) diff --git a/app/core/config.py b/app/core/config.py index e98d5e7..d295acc 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -79,6 +79,15 @@ class Settings(BaseSettings): "https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage" ) + # 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,取 AppId + AppSecret + # (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。 + HUAWEI_PUSH_APP_ID: str = "" + HUAWEI_PUSH_APP_SECRET: str = "" + HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token" + HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = ( + "https://push-api.cloud.huawei.com/v1/{app_id}/messages:send" + ) + VIVO_PUSH_APP_ID: str = "" VIVO_PUSH_APP_KEY: str = "" VIVO_PUSH_APP_SECRET: str = "" diff --git a/app/core/notification_catalog.py b/app/core/notification_catalog.py new file mode 100644 index 0000000..6a5b1bb --- /dev/null +++ b/app/core/notification_catalog.py @@ -0,0 +1,241 @@ +"""消息通知中心:13 种通知类型的静态目录 + Push 文案模板。 + +对应 PRD《消息通知中心》:§1 类型清单 / §3 字段元素 / §5 Push 文案。 +这里只放**静态定义**(分类、版式、标题、操作行、Push 模板),供两处消费: + - repositories/notification_mock.py 生成消息中心 mock 列表(虚拟数据阶段) + - api/v1/push.py 渲染 13 类 push 标题/文案(厂商推送 + 测试端点) + +PRD 文案规范(§5):push 标题 ≤11 字、固定文案不带变量;变量只出现在正文里且尽量前置。 +模板变量用 `{name}` 占位,渲染时缺省回退 sample_vars(PRD 示例值),保证 mock 阶段随时可发。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +# --------------------------------------------------------------------------- +# 分类(仅作卡片头部的分类标签展示;列表不按分类分组——PRD §1 的分组已确认取消,全表时间倒序) +# --------------------------------------------------------------------------- + +CATEGORY_WITHDRAW = "withdraw_assistant" +CATEGORY_SYSTEM = "system" +CATEGORY_FEEDBACK = "feedback" +CATEGORY_REPORT = "report" +CATEGORY_INVITE = "invite" + +# key → 中文标签 +CATEGORIES: dict[str, str] = { + CATEGORY_WITHDRAW: "提现助手", + CATEGORY_SYSTEM: "系统通知", + CATEGORY_FEEDBACK: "我的反馈", + CATEGORY_REPORT: "我的爆料", + CATEGORY_INVITE: "好友邀请", +} + + +def category_label(key: str) -> str: + return CATEGORIES[key] + + +# --------------------------------------------------------------------------- +# 卡片版式(PRD §3「版式」列;前端按此渲染五种卡) +# --------------------------------------------------------------------------- + +CARD_DUAL_AMOUNT = "dual_amount" # 双金额卡(金币数 + 现金数) +CARD_WITHDRAW = "withdraw" # 提现卡(¥金额) +CARD_PLAIN_TEXT = "plain_text" # 纯文本卡(无数值) +CARD_COIN_REWARD = "coin_reward" # 金币奖励卡(金币数 + 单位「金币」) +CARD_FRIEND_CASH = "friend_cash" # 好友现金卡(¥金额) + + +@dataclass(frozen=True) +class NotificationType: + """一种通知类型的静态定义(卡片元数据 + Push 模板)。""" + + key: str # 类型 key(接口 type 字段;前端按它决定点击跳转,见 PRD §2) + category: str # 分类 key(CATEGORIES 之一) + card_style: str # 卡片版式(CARD_* 之一) + card_title: str # 卡片标题(PRD §3「标题」列) + action_text: str | None # 操作行文案;None = 无操作行(如「提现成功」) + push_title: str # push 标题(≤11 字固定文案,PRD §5) + push_body_template: str # push 正文模板,`{var}` 为变量 + sample_vars: dict[str, str] = field(default_factory=dict) # PRD 示例值,渲染缺省回退 + + +# 13 种类型,编号/文案与 PRD §1/§3/§5 一一对应(插入顺序 = PRD 编号顺序)。 +TYPES: dict[str, NotificationType] = { + t.key: t + for t in [ + # -- 提现助手 ------------------------------------------------------- + NotificationType( + key="reward_expiring", + category=CATEGORY_WITHDRAW, + card_style=CARD_DUAL_AMOUNT, + card_title="金币现金奖励即将失效", + action_text="立即激活您的收益", + push_title="您的奖励即将失效", + push_body_template="{coins}金币和{cash}元现金{days}天后失效,完成快来激活收益", + sample_vars={"coins": "86", "cash": "12.80", "days": "3"}, + ), + NotificationType( + key="reward_expired", + category=CATEGORY_WITHDRAW, + card_style=CARD_DUAL_AMOUNT, + card_title="金币现金奖励已失效", + action_text="立即赚取新收益", + push_title="您的奖励已失效", + push_body_template="{coins}金币和{cash}元现金已过期,完成一次一键领券或一键比价可赚取新收益", + sample_vars={"coins": "35", "cash": "0.60"}, + ), + NotificationType( + key="withdraw_success", + category=CATEGORY_WITHDRAW, + card_style=CARD_WITHDRAW, + card_title="提现成功", + action_text=None, # PRD §3:提现成功卡无操作行,点击也无跳转、仅消红点 + push_title="提现到账提醒", + push_body_template="¥{amount}已存入您的微信钱包,点击查看到账详情", + sample_vars={"amount": "0.50"}, + ), + NotificationType( + key="withdraw_failed", + category=CATEGORY_WITHDRAW, + card_style=CARD_WITHDRAW, + card_title="提现失败,款项已退回", + action_text="重新提现", + push_title="提现失败,款项已退回", + push_body_template="¥{amount}因{reason}退回现金余额,点击重新提现", + sample_vars={"amount": "3.50", "reason": "微信零钱未实名"}, + ), + # -- 系统通知(权限异常 ×4;标题里的功能名按类型写死,见 PRD §1/§3)---- + NotificationType( + key="perm_accessibility", + category=CATEGORY_SYSTEM, + card_style=CARD_PLAIN_TEXT, + card_title="检测到您的比价功能已失效", + action_text="去开启", + push_title="检测到您的比价功能已失效", + push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启", + ), + NotificationType( + key="perm_battery", + category=CATEGORY_SYSTEM, + card_style=CARD_PLAIN_TEXT, + card_title="检测到您的比价续航保护已失效", + action_text="去开启", + push_title="检测到您的比价续航保护已失效", + push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启", + ), + NotificationType( + key="perm_autostart", + category=CATEGORY_SYSTEM, + card_style=CARD_PLAIN_TEXT, + card_title="检测到您的比价启动保护已失效", + action_text="去开启", + push_title="检测到您的比价启动保护已失效", + push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启", + ), + NotificationType( + key="perm_overlay", + category=CATEGORY_SYSTEM, + card_style=CARD_PLAIN_TEXT, + card_title="检测到您的比价按钮已失效", + action_text="去开启", + push_title="检测到您的比价按钮已失效", + push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启", + ), + # -- 我的反馈 ------------------------------------------------------- + NotificationType( + key="feedback_reply", + category=CATEGORY_FEEDBACK, + card_style=CARD_PLAIN_TEXT, + card_title="傻瓜比价官方回复了您的反馈", + action_text="查看详情", + push_title="您的反馈有回复啦", + push_body_template="您提的建议我们认真看过了,来看看我们的回复吧~", + ), + NotificationType( + key="feedback_reward", + category=CATEGORY_FEEDBACK, + card_style=CARD_COIN_REWARD, + card_title="反馈奖励", + action_text="查看反馈详情", + push_title="反馈奖励已到账", + push_body_template="谢谢您帮傻瓜比价变得更好,{coins}金币已到账,还有一条给您的留言~", + sample_vars={"coins": "300"}, + ), + # -- 我的爆料 ------------------------------------------------------- + NotificationType( + key="report_approved", + category=CATEGORY_REPORT, + card_style=CARD_COIN_REWARD, + card_title="爆料审核通过", + action_text="查看爆料详情", + push_title="爆料审核通过", + push_body_template="您爆料的「{store}」更低价审核通过,{coins}金币已到账,感谢您的分享", + sample_vars={"store": "蜀大侠火锅", "coins": "1000"}, + ), + # -- 好友邀请 ------------------------------------------------------- + NotificationType( + key="invite_order_reward", + category=CATEGORY_INVITE, + card_style=CARD_FRIEND_CASH, + card_title="好友比价成功,现金已到账", + action_text="邀请更多好友赚现金", + push_title="您的邀请奖励已到账", + push_body_template="您的好友「{nickname}」完成首次下单,{amount}元现金已到账", + sample_vars={"nickname": "柚子", "amount": "2"}, + ), + NotificationType( + key="invite_remind", + category=CATEGORY_INVITE, + card_style=CARD_PLAIN_TEXT, + card_title="你邀请的好友还差一步", + action_text="去提醒 TA", + push_title="提醒好友完成比价的奖励", + push_body_template="您的好友「{nickname}」还没完成比价下单,提醒TA完成,您可得{amount}元现金", + sample_vars={"nickname": "阿泽", "amount": "2"}, + ), + ] +} + + +class UnknownNotificationType(ValueError): + """type key 不在 13 种类型之内。""" + + +def get_type(type_key: str) -> NotificationType: + ntype = TYPES.get(type_key) + if ntype is None: + raise UnknownNotificationType( + f"unknown notification type: {type_key!r} (可选: {', '.join(TYPES)})" + ) + return ntype + + +def render_push(type_key: str, variables: dict[str, str] | None = None) -> tuple[str, str]: + """渲染某类型的 push (标题, 正文)。 + + variables 覆盖模板变量;缺的变量回退 sample_vars(PRD 示例值)——保证虚拟数据 + 阶段不传变量也能发出完整文案。多余的变量忽略。 + """ + ntype = get_type(type_key) + merged = {**ntype.sample_vars, **(variables or {})} + + class _Fallback(dict): + def __missing__(self, key: str) -> str: # 模板变量既没传也没示例值 → 保留 {key} 原样 + return "{" + key + "}" + + body = ntype.push_body_template.format_map(_Fallback(merged)) + return ntype.push_title, body + + +def push_variable_names(type_key: str) -> list[str]: + """列出模板里出现的变量名(给 /push/templates 预览用)。""" + import string + + ntype = get_type(type_key) + return [ + fname + for _, fname, _, _ in string.Formatter().parse(ntype.push_body_template) + if fname + ] diff --git a/app/integrations/vendor_push.py b/app/integrations/vendor_push.py index 92917e3..20c7973 100644 --- a/app/integrations/vendor_push.py +++ b/app/integrations/vendor_push.py @@ -1,7 +1,16 @@ -"""厂商直推集成。 +"""厂商直推集成(荣耀 / 华为 / 小米 / OPPO / vivo)。 -服务端不再经由 JPush Push API 发送无障碍召回通知,而是按客户端上报的 -push_vendor + push_token 分发到各手机厂商的服务端 API。 +服务端不经由 JPush Push API,而是按客户端上报的 push_vendor + push_token +分发到各手机厂商的服务端 API。 + +对外两个入口: + - send_notification() 通用:任意标题/正文/extras(消息中心 13 类推送走这里), + mock=True 时不真调厂商、返回渲染结果(虚拟数据联调用) + - send_accessibility_disabled() 旧:无障碍掉线召回(heartbeat_monitor_worker 在用), + 已改为 send_notification 的薄封装,行为不变 + +各厂商鉴权方式:荣耀/华为 OAuth client_credentials 换 access_token(进程内缓存); +vivo/OPPO 签名换 authToken(缓存 24h);小米直接 AppSecret 走 Authorization 头。 """ from __future__ import annotations @@ -21,7 +30,30 @@ from app.core.config import settings logger = logging.getLogger("shagua.vendor_push") TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled" -SUPPORTED_VENDORS = frozenset({"honor", "vivo", "xiaomi", "oppo"}) +SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"}) + +# vendor key → 中文名(测试/配置状态接口展示用) +VENDOR_LABELS: dict[str, str] = { + "honor": "荣耀", + "huawei": "华为", + "xiaomi": "小米", + "oppo": "OPPO", + "vivo": "vivo", +} + +# 各厂商真发推送所需的 settings 键(缺任一即视为未配置;/api/v1/push/vendors 据此报缺) +REQUIRED_SETTINGS: dict[str, tuple[str, ...]] = { + "honor": ("HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"), + "huawei": ("HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"), + "xiaomi": ("XIAOMI_PUSH_APP_SECRET",), + "oppo": ("OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"), + "vivo": ("VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"), +} + + +def missing_settings(vendor: str) -> list[str]: + """该厂商还缺哪些配置键(全配齐返回空列表)。vendor 需已 normalize。""" + return [key for key in REQUIRED_SETTINGS.get(vendor, ()) if not getattr(settings, key, "")] class VendorPushError(Exception): @@ -44,6 +76,10 @@ def normalize_vendor(push_vendor: str | None) -> str | None: aliases = { "hihonor": "honor", "荣耀": "honor", + "hms": "huawei", + "华为": "huawei", + "harmony": "huawei", + "harmonyos": "huawei", "mi": "xiaomi", "小米": "xiaomi", "oneplus": "oppo", @@ -52,6 +88,53 @@ def normalize_vendor(push_vendor: str | None) -> str | None: return aliases.get(vendor, vendor) +def send_notification( + push_vendor: str, + push_token: str, + *, + title: str, + body: str, + extras: dict[str, str] | None = None, + mock: bool = False, +) -> dict[str, Any]: + """按厂商 token 向单台设备发送一条通知(通用入口)。 + + - extras:透传给客户端的自定义键值(值统一 string,兼容各厂商限制)。消息中心推送约定 + 至少带 {"type": <13 种类型 key>, "notificationId": <站内消息 id>},客户端据此 + 深链落地 + 调 /notifications/read 同步置读(PRD §4 push 联动)。 + - mock=True:不真调厂商 API,校验参数后原样返回渲染结果(虚拟数据阶段联调/自动化测试用)。 + """ + vendor = normalize_vendor(push_vendor) + token = push_token.strip() if push_token else "" + if not vendor or vendor not in SUPPORTED_VENDORS: + raise VendorPushError(f"unsupported push vendor: {push_vendor}") + if not token: + raise VendorPushError("push token is empty") + extras = {str(k): str(v) for k, v in (extras or {}).items()} + + if mock: + logger.info( + "[mock push] vendor=%s token=%s... title=%s body=%s extras=%s", + vendor, token[:12], title, body, extras, + ) + return { + "mock": True, + "vendor": vendor, + "title": title, + "body": body, + "extras": extras, + } + + dispatch: dict[str, Callable[[str, str, str, dict[str, str]], dict[str, Any]]] = { + "honor": _send_honor, + "huawei": _send_huawei, + "vivo": _send_vivo, + "xiaomi": _send_xiaomi, + "oppo": _send_oppo, + } + return dispatch[vendor](token, title, body, extras) + + def send_accessibility_disabled( push_vendor: str, push_token: str, @@ -59,25 +142,14 @@ def send_accessibility_disabled( title: str = "保护已关闭", alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。", ) -> dict[str, Any]: - """按厂商 token 向单台设备发送无障碍掉线通知。""" - vendor = normalize_vendor(push_vendor) - token = push_token.strip() if push_token else "" - if not vendor or vendor not in SUPPORTED_VENDORS: - raise VendorPushError(f"unsupported push vendor: {push_vendor}") - if not token: - raise VendorPushError("push token is empty") - - dispatch: dict[str, Callable[[str, str, str], dict[str, Any]]] = { - "honor": _send_honor, - "vivo": _send_vivo, - "xiaomi": _send_xiaomi, - "oppo": _send_oppo, - } - return dispatch[vendor](token, title, alert) - - -def _extras() -> dict[str, str]: - return {"type": TYPE_ACCESSIBILITY_DISABLED} + """按厂商 token 向单台设备发送无障碍掉线通知(heartbeat_monitor_worker 在用,行为不变)。""" + return send_notification( + push_vendor, + push_token, + title=title, + body=alert, + extras={"type": TYPE_ACCESSIBILITY_DISABLED}, + ) def _require(value: str, name: str) -> str: @@ -174,18 +246,18 @@ def _honor_access_token() -> str: return _cache_put(cache_key, str(token), data.get("expires_in")) -def _send_honor(token: str, title: str, alert: str) -> dict[str, Any]: +def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID") access_token = _honor_access_token() payload = { - "data": json.dumps(_extras(), ensure_ascii=False), - "notification": {"title": title, "body": alert}, + "data": json.dumps(extras, ensure_ascii=False), + "notification": {"title": title, "body": body}, "android": { "ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s", "targetUserType": 1, "notification": { "title": title, - "body": alert, + "body": body, "clickAction": {"type": 3}, "importance": "NORMAL", }, @@ -208,6 +280,65 @@ def _send_honor(token: str, title: str, alert: str) -> dict[str, Any]: return data +def _huawei_access_token() -> str: + """华为 OAuth2 client_credentials 换 access_token(client_id 即 AGC 应用的 AppId)。""" + cache_key = "huawei" + cached = _cache_get(cache_key) + if cached: + return cached + app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID") + app_secret = _require(settings.HUAWEI_PUSH_APP_SECRET, "HUAWEI_PUSH_APP_SECRET") + data = _request_form( + "POST", + settings.HUAWEI_PUSH_TOKEN_ENDPOINT, + data={ + "grant_type": "client_credentials", + "client_id": app_id, + "client_secret": app_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + token = data.get("access_token") + if not token: + raise VendorPushError(f"huawei auth failed: {data}") + return _cache_put(cache_key, str(token), data.get("expires_in")) + + +def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: + """华为 Push Kit 下行消息(v1 messages:send)。成功码 '80000000'; + '80100000' 为部分成功(单 token 场景仍视为失败,错误里带原始响应便于排障)。""" + app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID") + access_token = _huawei_access_token() + payload = { + "validate_only": False, + "message": { + "data": json.dumps(extras, ensure_ascii=False), + "android": { + "ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s", + "notification": { + "title": title, + "body": body, + "click_action": {"type": 3}, + "importance": "NORMAL", + }, + }, + "token": [token], + }, + } + data = _request_json( + "POST", + settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), + json=payload, + headers={ + "Content-Type": "application/json; charset=UTF-8", + "Authorization": f"Bearer {access_token}", + }, + ) + if str(data.get("code", "")) != "80000000": + raise VendorPushError(f"huawei push failed: {data}") + return data + + def _vivo_auth_token() -> str: cache_key = "vivo" cached = _cache_get(cache_key) @@ -237,27 +368,27 @@ def _vivo_auth_token() -> str: return _cache_put(cache_key, str(token), 24 * 3600) -def _send_vivo(token: str, title: str, alert: str) -> dict[str, Any]: +def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID") auth_token = _vivo_auth_token() - body: dict[str, Any] = { + payload: dict[str, Any] = { "appId": app_id, "regId": token, "notifyType": settings.VIVO_PUSH_NOTIFY_TYPE, "title": title, - "content": alert, + "content": body, "timeToLive": settings.PUSH_TIME_TO_LIVE_SEC, "skipType": 1, "requestId": uuid.uuid4().hex, "pushMode": settings.VIVO_PUSH_MODE, - "clientCustomMap": _extras(), + "clientCustomMap": extras, } if settings.VIVO_PUSH_CATEGORY: - body["category"] = settings.VIVO_PUSH_CATEGORY + payload["category"] = settings.VIVO_PUSH_CATEGORY data = _request_json( "POST", settings.VIVO_PUSH_SEND_ENDPOINT, - json=body, + json=payload, headers={ "Content-Type": "application/json", "authToken": auth_token, @@ -268,31 +399,31 @@ def _send_vivo(token: str, title: str, alert: str) -> dict[str, Any]: return data -def _send_xiaomi(token: str, title: str, alert: str) -> dict[str, Any]: +def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET") message_title = settings.XIAOMI_PUSH_TEMPLATE_TITLE.strip() or title - message_description = settings.XIAOMI_PUSH_TEMPLATE_DESCRIPTION.strip() or alert - body = { + message_description = settings.XIAOMI_PUSH_TEMPLATE_DESCRIPTION.strip() or body + form = { "registration_id": token, "restricted_package_name": settings.ANDROID_PACKAGE_NAME, "title": message_title, "description": message_description, - "payload": json.dumps(_extras(), ensure_ascii=False), + "payload": json.dumps(extras, ensure_ascii=False), "pass_through": "0", "notify_type": "-1", "time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000), "extra.notify_effect": "1", } if settings.XIAOMI_PUSH_CHANNEL_ID: - body["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip() + form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip() if settings.XIAOMI_PUSH_TEMPLATE_ID: - body["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip() + form["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip() if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON: - body["extra.template_param"] = _xiaomi_template_param(title, alert) + form["extra.template_param"] = _xiaomi_template_param(title, body) data = _request_form( "POST", settings.XIAOMI_PUSH_SEND_ENDPOINT, - data=body, + data=form, headers={"Authorization": f"key={app_secret}"}, ) code = data.get("code") @@ -350,20 +481,20 @@ def _oppo_auth_token() -> str: return _cache_put(cache_key, str(token), 24 * 3600) -def _send_oppo(token: str, title: str, alert: str) -> dict[str, Any]: +def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: auth_token = _oppo_auth_token() ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600)) message = { "target_type": 2, "target_value": token, "notification": { - "app_message_id": f"accessibility_disabled_{uuid.uuid4().hex}", + "app_message_id": f"{extras.get('type', 'notify')}_{uuid.uuid4().hex}", "title": title, - "content": alert, + "content": body, "click_action_type": 0, "off_line": True, "off_line_ttl": ttl_hours, - "action_parameters": json.dumps(_extras(), ensure_ascii=False), + "action_parameters": json.dumps(extras, ensure_ascii=False), }, } data = _request_form( diff --git a/app/main.py b/app/main.py index 6aded37..953663f 100644 --- a/app/main.py +++ b/app/main.py @@ -31,8 +31,10 @@ from app.api.v1.device import router as device_router from app.api.v1.feedback import router as feedback_router from app.api.v1.invite import router as invite_router from app.api.v1.meituan import router as meituan_router +from app.api.v1.notifications import router as notifications_router from app.api.v1.order import router as order_router from app.api.v1.platform import router as platform_router +from app.api.v1.push import router as push_router from app.api.v1.report import router as report_router from app.api.v1.savings import router as savings_router from app.api.v1.signin import router as signin_router @@ -126,6 +128,10 @@ app.include_router(savings_router) app.include_router(ad_router) app.include_router(order_router) app.include_router(report_router) +# 消息通知中心(PRD;虚拟数据阶段:mock 数据在内存,见 repositories/notification_mock.py) +app.include_router(notifications_router) +# 厂商推送测试三件套(配置状态/模板预览/测试发送,支持 mock 与真发) +app.include_router(push_router) # 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。 app.include_router(internal_price_router) app.include_router(internal_store_router) diff --git a/app/repositories/notification_mock.py b/app/repositories/notification_mock.py new file mode 100644 index 0000000..f45c078 --- /dev/null +++ b/app/repositories/notification_mock.py @@ -0,0 +1,328 @@ +"""消息通知中心 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) diff --git a/app/schemas/notification.py b/app/schemas/notification.py new file mode 100644 index 0000000..08fcfc9 --- /dev/null +++ b/app/schemas/notification.py @@ -0,0 +1,128 @@ +"""消息通知中心 请求/响应契约。 + +⚠️ 命名约定:本组接口按 PRD 前端契约使用 **camelCase**(sentAt / isRead / pageSize …), +与库内其他 snake_case 接口不同——PRD 与前端原型(notifications.html)按 camelCase 对接, +需求方接口清单亦明确写作 sentAt / isRead,故整组遵循之。响应序列化走 pydantic alias。 + +字段说明都写在 Field(description=...) 里,起服务后打开 /docs 即是给前端的在线文档。 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class _CamelModel(BaseModel): + """出参统一 camelCase(alias);populate_by_name 允许服务端代码仍用 snake_case 构造。""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class InfoRow(_CamelModel): + """卡片信息行(PRD §3「信息行」列),前端按 label: value 逐行渲染。""" + + label: str = Field(description="行标签,如「过期说明」「到账账户」「失败原因」") + value: str = Field(description="行内容(已按 PRD 文案拼好变量,前端直接展示)") + + +class NotificationItem(_CamelModel): + """一条通知卡片。 + + 卡片头部三要素:categoryLabel(分类标签)+ 未读红点(isRead=false 时展示)+ 时间(sentAt)。 + 时间显示规则(前端处理):今天→「今天」;昨天→「昨天」;当年→「M月D日」;跨年→「YYYY年M月D日」。 + """ + + id: int = Field(description="通知 id(未读消除、push 联动都用它)") + category: str = Field( + description="分类 key:withdraw_assistant=提现助手 / system=系统通知 / " + "feedback=我的反馈 / report=我的爆料 / invite=好友邀请" + ) + category_label: str = Field(description="分类中文标签(卡片头部直接展示)") + type: str = Field( + description="类型 key(13 种,决定点击行为,见 PRD §2):reward_expiring 即将失效 / " + "reward_expired 已失效 / withdraw_success 提现成功 / withdraw_failed 提现失败 / " + "perm_accessibility 无障碍异常 / perm_battery 省电策略异常 / " + "perm_autostart 自启动异常 / perm_overlay 悬浮窗异常 / " + "feedback_reply 官方回复 / feedback_reward 反馈奖励 / " + "report_approved 爆料审核通过 / invite_order_reward 好友下单奖励 / " + "invite_remind 好友催单提醒" + ) + card_style: str = Field( + description="卡片版式:dual_amount 双金额卡 / withdraw 提现卡 / plain_text 纯文本卡 / " + "coin_reward 金币奖励卡 / friend_cash 好友现金卡" + ) + title: str = Field(description="卡片标题(双金额/提现/金币奖励/好友现金卡标题居中)") + coins: int | None = Field( + default=None, + description="金币数(整数,不带小数)。dual_amount / coin_reward 卡有值,其余 null", + ) + cash_cents: int | None = Field( + default=None, + description="现金金额,单位【分】。dual_amount / withdraw / friend_cash 卡有值,其余 null", + ) + cash_yuan: str | None = Field( + default=None, + description="现金金额展示串(元,保留两位小数,如 \"12.80\"),与 cashCents 同源,可直接展示", + ) + info_rows: list[InfoRow] = Field( + description="信息行列表(label: value),内容已按 PRD §3 拼好,前端逐行渲染即可" + ) + action_text: str | None = Field( + default=None, + description="操作行文案(如「立即激活您的收益」「去开启」);null=无操作行(提现成功卡)。" + "注意:点击目标是整张卡片,不区分卡片主体和操作行", + ) + extra: dict[str, Any] = Field( + description="点击跳转所需业务参数,按 type 取用:perm_* → {permission: accessibility|battery|" + "autostart|overlay}(点击时实时检测该权限);feedback_* → {feedbackId};" + "report_approved → {reportId};withdraw_failed → {withdrawId};" + "invite_order_reward → {inviteeNickname};invite_remind → " + "{inviteeNickname, scrollTo:\"remind\"};reward_expiring → {batchId}" + ) + sent_at: datetime = Field(description="下发时间(ISO8601 带 +08:00 时区),前端按显示规则格式化") + is_read: bool = Field(description="是否已读;false 时分类标签右侧展示 6px 红点(#E53935)") + + +class NotificationListOut(_CamelModel): + """GET /api/v1/notifications 出参。列表已按时间倒序排好(最新在前,**不分组**; + PRD §1 的"按分类分组"为笔误,已确认取消),前端无需再排。""" + + items: list[NotificationItem] = Field(description="当前页通知卡片") + page: int = Field(description="当前页码(1 起)") + page_size: int = Field(description="每页条数") + total: int = Field(description="全部通知总条数(含已读)") + has_more: bool = Field(description="是否还有下一页") + unread_count: int = Field(description="当前未读总数(与 /notifications/unread-count 同口径,省一次请求)") + + +class UnreadCountOut(_CamelModel): + """GET /api/v1/notifications/unread-count 出参(首页铃铛角标)。""" + + count: int = Field(description="未读总条数(精确值)") + badge_text: str | None = Field( + description="角标展示文案:超过 99 返回 \"99+\";等于 0 返回 null(整个角标隐藏,不展示空红点)" + ) + + +class MarkReadRequest(_CamelModel): + """POST /api/v1/notifications/read 入参,两种模式二选一: + + - `{"ids": [90001, 90002]}` 单条/多条置读——点击某张卡片、点击 push 落地后同步置读; + - `{"all": true}` 全量清零——进入通知中心(或退出时)自动清零(PRD §4)。 + + 同时传时 all=true 优先;不存在/已读的 id 自动忽略(幂等,可放心重试)。 + """ + + ids: list[int] | None = Field(default=None, description="要置为已读的通知 id 列表") + all: bool = Field(default=False, description="true=清空该用户全部未读") + + +class MarkReadOut(_CamelModel): + """POST /api/v1/notifications/read 出参。""" + + ok: bool = Field(description="固定 true(参数非法时走 400,不会到这里)") + marked_count: int = Field(description="本次实际由未读变为已读的条数(重复请求会是 0)") + unread_count: int = Field(description="处理后的剩余未读总数,可直接刷新铃铛角标") diff --git a/app/schemas/push.py b/app/schemas/push.py new file mode 100644 index 0000000..f688b0a --- /dev/null +++ b/app/schemas/push.py @@ -0,0 +1,99 @@ +"""厂商推送(测试/联调)接口契约。与消息中心同族,出参统一 camelCase。""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class _CamelModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class PushVendorStatus(_CamelModel): + vendor: str = Field(description="厂商 key:honor / huawei / xiaomi / oppo / vivo") + label: str = Field(description="厂商中文名") + configured: bool = Field(description="服务端凭据是否齐全(齐全才能真发,mock 不受影响)") + missing_keys: list[str] = Field(description="缺失的 .env 配置键;configured=true 时为空") + + +class PushVendorsOut(_CamelModel): + vendors: list[PushVendorStatus] = Field(description="5 个厂商的配置状态") + + +class PushTemplateOut(_CamelModel): + type: str = Field(description="通知类型 key(13 种,与消息中心 type 一致)") + category: str = Field(description="分类 key") + category_label: str = Field(description="分类中文标签") + card_style: str = Field(description="站内卡片版式") + push_title: str = Field(description="push 标题(≤11 字固定文案,PRD §5)") + push_body_sample: str = Field(description="push 正文示例(模板用 PRD 示例值渲染后的效果)") + push_body_template: str = Field(description="push 正文模板原文,{var} 为变量占位") + variables: list[str] = Field(description="模板变量名列表(调 /push/test 时可在 vars 里覆盖)") + sample_vars: dict[str, str] = Field(description="各变量的 PRD 示例值(vars 未覆盖时的缺省)") + + +class PushTemplatesOut(_CamelModel): + templates: list[PushTemplateOut] = Field(description="13 种通知类型的 push 模板(PRD 编号顺序)") + + +class PushTestRequest(_CamelModel): + """POST /api/v1/push/test 入参。三种发送内容来源(优先级从高到低): + + 1. 直接指定 title + content; + 2. 指定 type(13 种之一)→ 按 PRD §5 模板渲染,vars 可覆盖模板变量; + 3. 都不传 → 发一条通用测试文案。 + + 推送目标:pushToken 直填,或 deviceId 反查该用户已注册设备(/api/v1/device/register 上报过的)。 + """ + + vendor: str = Field( + default="", + description="厂商:honor/huawei/xiaomi/oppo/vivo(中文「华为」「小米」等别名也识别)。" + "留空时用 deviceId 对应设备上报的 push_vendor", + ) + push_token: str = Field(default="", description="厂商 push token / regId;留空则走 deviceId 反查") + device_id: str = Field(default="", description="设备 id(客户端 DeviceId.get());用于反查 token") + type: str = Field( + default="", + description="通知类型 key(13 种,见 GET /push/templates);留空且未直接给 title/content 时发通用测试文案", + ) + vars: dict[str, str] = Field( + default_factory=dict, + description="覆盖 push 模板变量,如 {\"coins\":\"520\",\"cash\":\"6.66\"};缺省用 PRD 示例值", + ) + title: str = Field(default="", description="直接指定标题(优先于 type 模板)") + content: str = Field(default="", description="直接指定正文(优先于 type 模板)") + create_notification: bool = Field( + default=False, + description="true=同时往该用户的消息中心 mock 列表插入一条同类型未读通知,push extras 带上它的" + " notificationId → 可闭环验证「点 push → 落地 → 调 /notifications/read 消红点」联动" + "(仅 type 为 13 种类型之一时生效)", + ) + mock: bool = Field( + default=True, + description="true(默认)=不真调厂商 API,返回渲染结果(联调安全);false=真发,要求该厂商凭据已配置", + ) + + +class PushTestOut(_CamelModel): + ok: bool = Field(description="发送(或 mock 渲染)成功") + mock: bool = Field(description="本次是否 mock(未真调厂商 API)") + vendor: str = Field(description="实际使用的厂商 key(已归一化)") + title: str = Field(description="实际下发的 push 标题") + body: str = Field(description="实际下发的 push 正文") + extras: dict[str, str] = Field( + description="随 push 下发的自定义键值(客户端深链用):type 必有;createNotification=true 时带" + " notificationId 及该通知的业务参数(feedbackId / permission / …)" + ) + notification_id: int | None = Field( + default=None, description="createNotification=true 时新插入的站内 mock 通知 id" + ) + missing_keys: list[str] = Field( + default_factory=list, + description="该厂商仍缺失的配置键(mock 发送时提示「真发前还需配什么」;真发时必为空)", + ) + vendor_response: dict[str, Any] | None = Field( + default=None, description="真发时厂商 API 的原始响应(mock 时为 null)" + ) diff --git a/docs/api/README.md b/docs/api/README.md index 6c7bcd0..61b8792 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,9 +1,9 @@ # 傻瓜比价 App 后端 — API 接口文档(索引) > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` -> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** +> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档) > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` -> 最后更新:2026-06-23(补全此前缺整族的端点:device 无障碍存活监控、internal 内部回写、CPS 短链落地、report/invite/wxpay,及 platform/coupon/feedback/user 的零散读端点;device/internal/cps-redirect 三族新建单文件文档。上一次 2026-06-18 补录比价透传端点与 `meituan/top-sales`) +> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点) > 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。 --- @@ -99,6 +99,14 @@ | 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) | | 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) | | 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) | +| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) ||| +| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) | +| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) | +| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) | +| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase) ||| +| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) | +| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) | +| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) | | **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) ||| | 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) | | 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) | diff --git a/docs/api/notifications.md b/docs/api/notifications.md new file mode 100644 index 0000000..458949a --- /dev/null +++ b/docs/api/notifications.md @@ -0,0 +1,121 @@ +# 消息通知中心(notifications 族) + +> 所属:notifications 组(前缀 `/api/v1/notifications`,源 `app/api/v1/notifications.py`) | 鉴权:**全部 Bearer**(消息按用户隔离) | [← 返回 API 索引](./README.md) +> +> 对应 PRD《消息通知中心》(通知类型清单 / 点击跳转 / 字段元素 / 未读红点 / Push 文案)。 +> Push 侧(厂商直推 + 测试)见 [push-vendor-test.md](./push-vendor-test.md)。 +> +> ⚠️ **虚拟数据阶段**:数据来自内存 mock(`app/repositories/notification_mock.py`),每个用户首次访问生成一套固定样例(13 种类型全覆盖 + 今天/昨天/当年/跨年四种时间 + 已读未读混合,共 16 条,id 从 90001 起);已读状态在服务端内存维护,**重启服务复位**。后续接真实数据只换 repository 实现,接口契约不变。 +> +> ⚠️ **字段命名**:本组接口(含 push 测试组)对外为 **camelCase**(`sentAt` / `isRead` / `pageSize`…),与库内其他 snake_case 接口不同——按 PRD 前端契约对接,勿混用。 + +## 通知类型速查(13 种) + +列表**服务端已排好序:全列表按时间倒序**(最新在前,**不做分类分组**——PRD §1 的"按分类分组"为笔误,2026-07-14 需求方确认取消),前端按返回顺序渲染即可。category 仅用于卡片头部的分类标签展示。 + +| category | 分类标签 | type | 类型 | cardStyle 版式 | actionText 操作行 | extra 里带什么 | +|---|---|---|---|---|---|---| +| withdraw_assistant | 提现助手 | `reward_expiring` | 金币现金奖励即将失效 | dual_amount 双金额卡 | 立即激活您的收益 | `batchId` | +| withdraw_assistant | 提现助手 | `reward_expired` | 金币现金奖励已失效 | dual_amount 双金额卡 | 立即赚取新收益 | — | +| withdraw_assistant | 提现助手 | `withdraw_success` | 提现成功 | withdraw 提现卡 | **null(无操作行,点击仅消红点)** | — | +| withdraw_assistant | 提现助手 | `withdraw_failed` | 提现失败,款项已退回 | withdraw 提现卡 | 重新提现 | `withdrawId` | +| system | 系统通知 | `perm_accessibility` | 比价功能异常(无障碍) | plain_text 纯文本卡 | 去开启 | `permission:"accessibility"` | +| system | 系统通知 | `perm_battery` | 比价续航保护异常(省电策略) | plain_text 纯文本卡 | 去开启 | `permission:"battery"` | +| system | 系统通知 | `perm_autostart` | 比价启动保护异常(自启动) | plain_text 纯文本卡 | 去开启 | `permission:"autostart"` | +| system | 系统通知 | `perm_overlay` | 比价按钮异常(悬浮窗) | plain_text 纯文本卡 | 去开启 | `permission:"overlay"` | +| feedback | 我的反馈 | `feedback_reply` | 官方回复 | plain_text 纯文本卡 | 查看详情 | `feedbackId` | +| feedback | 我的反馈 | `feedback_reward` | 反馈奖励(必带官方留言行) | coin_reward 金币奖励卡 | 查看反馈详情 | `feedbackId` | +| report | 我的爆料 | `report_approved` | 爆料审核通过 | coin_reward 金币奖励卡 | 查看爆料详情 | `reportId` | +| invite | 好友邀请 | `invite_order_reward` | 好友下单奖励到账 | friend_cash 好友现金卡 | 邀请更多好友赚现金 | `inviteeNickname` | +| invite | 好友邀请 | `invite_remind` | 好友催单提醒 | plain_text 纯文本卡 | 去提醒 TA | `inviteeNickname`, `scrollTo:"remind"` | + +点击跳转逻辑按 PRD §2 由客户端按 `type` 分发;点击目标 = 整张卡片(不区分主体和操作行),任何点击都先调 `POST /read` 消该条红点。 + +## GET /api/v1/notifications — 消息列表(分页) + +**入参(query)** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `page` | int | ❌ | 页码,1 起,默认 1 | +| `pageSize` | int | ❌ | 每页条数,默认 20,最大 100 | + +**出参** + +```jsonc +{ + "items": [ + { + "id": 90001, + "category": "withdraw_assistant", // 分类 key(5 种,见上表) + "categoryLabel": "提现助手", // 卡片头部左上角分类标签 + "type": "reward_expiring", // 类型 key(13 种,决定点击行为) + "cardStyle": "dual_amount", // 版式:dual_amount/withdraw/plain_text/coin_reward/friend_cash + "title": "金币现金奖励即将失效", // 卡片标题 + "coins": 86, // 金币数,整数;无金币的版式为 null + "cashCents": 1280, // 现金金额(分);无现金的版式为 null + "cashYuan": "12.80", // 现金展示串(元,两位小数),与 cashCents 同源 + "infoRows": [ // 信息行,已按 PRD 拼好文案,逐行 label: value 渲染 + { "label": "过期说明", "value": "您有86金币和12.80元现金即将失效,完成一次一键领券或一键比价即可激活收益" }, + { "label": "过期时间", "value": "3天后失效" } + ], + "actionText": "立即激活您的收益", // 操作行;null = 无操作行(提现成功卡) + "extra": { "batchId": "batch_20260714" }, // 跳转/联动参数,按 type 取用(见上表) + "sentAt": "2026-07-14T14:59:58+08:00", // ISO8601 带时区 + "isRead": false // false → 分类标签右侧显示 6px 红点(#E53935) + } + ], + "page": 1, + "pageSize": 20, + "total": 16, + "hasMore": false, + "unreadCount": 12 // 与 /unread-count 同口径,进页面可顺手刷角标 +} +``` + +**时间显示规则(前端处理 `sentAt`)**:今天 →「今天」;昨天 →「昨天」;当年 →「M月D日」(不补零);跨年 →「YYYY年M月D日」。mock 样例特意覆盖了这四种情况。 + +**数值约束(PRD §3)**:金币整数不带小数;现金/提现金额两位小数(直接用 `cashYuan`)。 + +## GET /api/v1/notifications/unread-count — 未读总数(首页铃铛角标) + +无入参。**出参**: + +```jsonc +{ "count": 12, "badgeText": "12" } // count>99 时 badgeText="99+";count=0 时 badgeText=null → 整个角标隐藏 +``` + +刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时(原型监听 `pageshow`)。 + +## POST /api/v1/notifications/read — 标记已读 + +**入参(JSON),两种模式二选一(同时传时 `all` 优先)** + +| 模式 | body | 使用场景 | +|---|---|---| +| 单条/多条 | `{ "ids": [90001, 90003] }` | ① 点击某张消息卡片(点击后无论跳转/弹窗/无动作都算已读);② 用户点击 push 直达落地页后,客户端拿 push extras 里的 `notificationId` 同步置读 | +| 全量清零 | `{ "all": true }` | 进入通知中心自动清零(只浏览列表就消红点,无需逐条点击;退出通知中心时也可再调一次兜底) | + +**出参** + +```jsonc +{ "ok": true, "markedCount": 2, "unreadCount": 10 } // unreadCount = 处理后剩余未读,可直接刷新角标 +``` + +幂等:不存在/已读的 id 忽略,重复调用 `markedCount=0` 不报错。 + +**错误**:`400` ids 与 all 都没传(或 ids 为空数组);`401` 未鉴权。 + +## 联调小抄 + +```bash +# 1. 登录拿 token(SMS mock:任意手机号 + 任意 6 位验证码) +curl -X POST :8770/api/v1/auth/sms/send -d '{"phone":"13800001234"}' +curl -X POST :8770/api/v1/auth/sms/login -d '{"phone":"13800001234","code":"123456"}' +# 2. 列表 / 角标 / 置读 +curl ":8770/api/v1/notifications?page=1&pageSize=20" -H "Authorization: Bearer $TOKEN" +curl ":8770/api/v1/notifications/unread-count" -H "Authorization: Bearer $TOKEN" +curl -X POST ":8770/api/v1/notifications/read" -d '{"all":true}' -H "Authorization: Bearer $TOKEN" +``` + +要重置 mock 数据(重新生成整套样例):重启服务即可。想造「push → 站内联动」数据,用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true`。 diff --git a/docs/api/push-vendor-test.md b/docs/api/push-vendor-test.md new file mode 100644 index 0000000..672abd5 --- /dev/null +++ b/docs/api/push-vendor-test.md @@ -0,0 +1,102 @@ +# 厂商推送测试三件套(push 族) + +> 所属:push 组(前缀 `/api/v1/push`,源 `app/api/v1/push.py`) | 鉴权:**全部 Bearer** | [← 返回 API 索引](./README.md) +> +> 发送实现:`app/integrations/vendor_push.py`(荣耀 / **华为** / 小米 / OPPO / vivo 五通道, +> `send_notification()` 通用入口)。站内消息中心见 [notifications.md](./notifications.md)。 +> 与 `POST /api/v1/device/push-test`(无障碍召回通道延迟自测)互补:本组面向消息中心 13 类 push 的文案/参数/通道联调。 +> +> 字段命名同 notifications 族:**camelCase**。 + +## 链路总览 + +``` +真实业务事件(奖励过期/提现回执/审核通过/…) + └→ vendor_push.send_notification(vendor, token, title, body, extras) + extras = { type, notificationId, ...业务参数 } ← 客户端深链 + 已读联动的钥匙 +客户端点击 push → 按 extras.type 直达落地页(与站内点击一致) + → 调 POST /notifications/read {ids:[extras.notificationId]} 同步消红点(PRD §4) +``` + +推送目标来源:客户端集成各厂商 push SDK 拿到 regId/token 后,通过 `POST /api/v1/device/register` 上报 `push_vendor` + `push_token`,服务端存 `device_liveness` 表。 + +## GET /api/v1/push/vendors — 厂商配置状态 + +检查 5 家厂商服务端凭据是否配齐(只读 .env,不打厂商接口)。`missingKeys` 即还要补的配置键;mock 测试不依赖任何凭据。 + +```jsonc +{ "vendors": [ + { "vendor": "honor", "label": "荣耀", "configured": false, "missingKeys": ["HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"] }, + { "vendor": "huawei", "label": "华为", "configured": false, "missingKeys": ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"] }, + { "vendor": "xiaomi", "label": "小米", "configured": true, "missingKeys": [] }, + { "vendor": "oppo", "label": "OPPO", "configured": false, "missingKeys": ["OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"] }, + { "vendor": "vivo", "label": "vivo", "configured": false, "missingKeys": ["VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"] } +] } +``` + +## GET /api/v1/push/templates — 13 类通知的 push 模板预览 + +PRD §5 的 13 条 push 文案(标题固定 ≤11 字不带变量;正文 `{var}` 为变量,示例值即 PRD 示例)。对文案、看变量名用。 + +```jsonc +{ "templates": [ + { + "type": "withdraw_success", + "category": "withdraw_assistant", "categoryLabel": "提现助手", "cardStyle": "withdraw", + "pushTitle": "提现到账提醒", + "pushBodySample": "¥0.50已存入您的微信钱包,点击查看到账详情", // 用示例值渲染后的效果 + "pushBodyTemplate": "¥{amount}已存入您的微信钱包,点击查看到账详情", + "variables": ["amount"], + "sampleVars": { "amount": "0.50" } + } + // ... 共 13 条,顺序即 PRD 编号 +] } +``` + +## POST /api/v1/push/test — 测试发送(默认 mock) + +**入参(JSON)** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `vendor` | string | ❌* | `honor/huawei/xiaomi/oppo/vivo`,中文「华为」「小米」等别名也识别;留空时用 `deviceId` 设备上报的 vendor | +| `pushToken` | string | ❌* | 厂商 push token/regId;留空则按 `deviceId` 反查已注册设备(*mock 模式两者都缺时用占位 token,只看渲染结果*) | +| `deviceId` | string | ❌ | 客户端 `DeviceId.get()` 的设备 id,用于反查 vendor+token | +| `type` | string | ❌ | 13 种类型 key 之一 → 按 PRD 模板渲染;不传且没直给文案 → 发通用测试文案 | +| `vars` | object | ❌ | 覆盖模板变量,如 `{"coins":"520","cash":"6.66"}`;缺省用 PRD 示例值 | +| `title` / `content` | string | ❌ | 直接指定标题/正文(优先于 type 模板) | +| `createNotification` | bool | ❌ | true = 同时往该用户消息中心插一条同类型未读 mock 通知,extras 带其 `notificationId` → 可闭环验证「点 push → 调 /notifications/read 消红点」(仅 type 合法时生效) | +| `mock` | bool | ❌ | **默认 true = 不真调厂商 API**,回显渲染结果;false = 真发到手机(要求该厂商凭据已配) | + +**出参** + +```jsonc +{ + "ok": true, "mock": true, "vendor": "huawei", + "title": "反馈奖励已到账", + "body": "谢谢您帮傻瓜比价变得更好,300金币已到账,还有一条给您的留言~", + "extras": { "type": "feedback_reward", "feedbackId": "3002", "notificationId": "90017" }, + "notificationId": 90017, // createNotification=true 时的站内 mock 通知 id + "missingKeys": ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"], // 真发前还缺的配置(真发成功时必为空) + "vendorResponse": null // 真发时为厂商 API 原始响应 +} +``` + +**错误**:`400` vendor/type 非法、真发但凭据未配(detail 列缺失键);`409` 真发但拿不到 pushToken;`502` 厂商 API 返回失败(detail 带厂商原始错误)。 + +**真发注意**: +- 目标手机必须先装 App 且客户端已集成对应厂商 SDK、`/device/register` 上报过 token; +- vivo 未上架前走测试推送(`VIVO_PUSH_MODE=1`),目标手机需在 vivo 开放平台加入测试设备; +- 小米新设备需在开放平台把签名/包名配好,token 才有效。 + +## 厂商凭据怎么拿(.env 键名) + +| 厂商 | 后台 | 需要的键 | +|---|---|---| +| 华为 | AGC 控制台 → 项目设置 → 常规 → 应用 | `HUAWEI_PUSH_APP_ID`、`HUAWEI_PUSH_APP_SECRET`(OAuth client_id 即 AppId) | +| 荣耀 | 荣耀开发者服务平台 → 推送服务 | `HONOR_PUSH_APP_ID`、`HONOR_PUSH_CLIENT_ID`、`HONOR_PUSH_CLIENT_SECRET` | +| 小米 | 开放平台 → 消息推送 → 应用秘钥 | `XIAOMI_PUSH_APP_SECRET`(服务端只要这个;AppID/AppKey 是客户端 SDK 用) | +| OPPO | 开放平台 → 推送服务 | `OPPO_PUSH_APP_KEY`、`OPPO_PUSH_MASTER_SECRET`(注意是**服务端 MasterSecret**) | +| vivo | 开放平台 → 推送 | `VIVO_PUSH_APP_ID`、`VIVO_PUSH_APP_KEY`、`VIVO_PUSH_APP_SECRET` | + +各家发送协议差异(鉴权方式/成功码/payload 结构)封装在 `integrations/vendor_push.py`,业务侧只面对 `send_notification()`。 diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..8a98bba --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,193 @@ +"""消息通知中心 3 接口(mock 数据阶段)。 + +覆盖:列表字段/排序/分页、未读角标、标记已读(ids / all / 幂等 / 参数校验)、 +鉴权与用户间隔离。数据来自 repositories/notification_mock.py 的内存 mock。 +""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.core import notification_catalog as catalog +from app.repositories import notification_mock as mock_repo + + +def _login(client: TestClient, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _fetch_all(client: TestClient, token: str) -> dict: + r = client.get("/api/v1/notifications?pageSize=100", headers=_auth(token)) + assert r.status_code == 200, r.text + return r.json() + + +def test_requires_auth(client: TestClient) -> None: + assert client.get("/api/v1/notifications").status_code == 401 + assert client.get("/api/v1/notifications/unread-count").status_code == 401 + assert client.post("/api/v1/notifications/read", json={"all": True}).status_code == 401 + + +def test_list_covers_all_types_with_camel_case_fields(client: TestClient) -> None: + token = _login(client, "13900010001") + data = _fetch_all(client, token) + + assert data["total"] >= 13 + assert data["unreadCount"] > 0 + items = data["items"] + assert len(items) == data["total"] + + # 13 种类型全覆盖 + assert {i["type"] for i in items} == set(catalog.TYPES) + + # 字段按 PRD 契约 camelCase,且卡片要素齐全 + first = items[0] + for key in ( + "id", "category", "categoryLabel", "type", "cardStyle", "title", + "coins", "cashCents", "cashYuan", "infoRows", "actionText", + "extra", "sentAt", "isRead", + ): + assert key in first, f"missing field {key}" + assert "+08:00" in first["sentAt"] + + # 双金额卡:金币 + 现金都有;金币为整数、现金串两位小数 + expiring = next(i for i in items if i["type"] == "reward_expiring") + assert isinstance(expiring["coins"], int) + assert expiring["cashYuan"] == "12.80" + assert expiring["cashCents"] == 1280 + assert expiring["actionText"] == "立即激活您的收益" + assert [row["label"] for row in expiring["infoRows"]] == ["过期说明", "过期时间"] + + # 提现成功卡:无操作行、无跳转 + ws = next(i for i in items if i["type"] == "withdraw_success") + assert ws["actionText"] is None + assert ws["coins"] is None + + # 权限异常卡带 permission 参数(客户端点击时实时检测用) + perm = next(i for i in items if i["type"] == "perm_accessibility") + assert perm["extra"] == {"permission": "accessibility"} + + # 反馈奖励卡:官方留言必填(PRD §3) + reward = next(i for i in items if i["type"] == "feedback_reward") + assert any(row["label"] == "官方留言" and row["value"] for row in reward["infoRows"]) + + +def test_list_sorted_by_time_desc_without_grouping(client: TestClient) -> None: + """全列表时间倒序、不按分类分组(PRD 原文的分组已确认取消)。""" + token = _login(client, "13900010002") + items = _fetch_all(client, token)["items"] + + sent_ats = [i["sentAt"] for i in items] + assert sent_ats == sorted(sent_ats, reverse=True), "全列表时间倒序,最新在前" + + # mock 样例里「好友下单(10 分钟前)」比「昨天的提现失败」新但分类靠后 → + # 若还在按分类分组,它不可能排进前 3;以此钉死"确实没分组"。 + newest_types = {i["type"] for i in items[:3]} + assert "invite_order_reward" in newest_types + + +def test_pagination(client: TestClient) -> None: + token = _login(client, "13900010003") + total = _fetch_all(client, token)["total"] + + page_size = 5 + seen_ids: list[int] = [] + page = 1 + while True: + r = client.get( + f"/api/v1/notifications?page={page}&pageSize={page_size}", headers=_auth(token) + ) + assert r.status_code == 200 + data = r.json() + assert data["page"] == page + assert data["pageSize"] == page_size + assert data["total"] == total + seen_ids.extend(i["id"] for i in data["items"]) + if not data["hasMore"]: + assert len(data["items"]) <= page_size + break + assert len(data["items"]) == page_size + page += 1 + + assert len(seen_ids) == total + assert len(set(seen_ids)) == total, "翻页不重不漏" + + # 超出末页 → 空页而非报错 + r = client.get("/api/v1/notifications?page=99&pageSize=50", headers=_auth(token)) + assert r.status_code == 200 + assert r.json()["items"] == [] + assert r.json()["hasMore"] is False + + +def test_unread_count_and_badge(client: TestClient) -> None: + token = _login(client, "13900010004") + data = _fetch_all(client, token) + expect_unread = sum(1 for i in data["items"] if not i["isRead"]) + + r = client.get("/api/v1/notifications/unread-count", headers=_auth(token)) + assert r.status_code == 200 + body = r.json() + assert body["count"] == expect_unread == data["unreadCount"] + assert body["badgeText"] == str(expect_unread) # mock 样例未读数在 1~99 区间 + + # 全部读完 → count=0,badgeText=null(整个角标隐藏) + client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token)) + r = client.get("/api/v1/notifications/unread-count", headers=_auth(token)) + assert r.json() == {"count": 0, "badgeText": None} + + +def test_mark_read_by_ids_idempotent(client: TestClient) -> None: + token = _login(client, "13900010005") + items = _fetch_all(client, token)["items"] + unread_ids = [i["id"] for i in items if not i["isRead"]] + before = len(unread_ids) + picked = unread_ids[:2] + + r = client.post("/api/v1/notifications/read", json={"ids": picked}, headers=_auth(token)) + assert r.status_code == 200 + assert r.json() == {"ok": True, "markedCount": 2, "unreadCount": before - 2} + + # 列表状态同步翻转 + items = _fetch_all(client, token)["items"] + assert all(i["isRead"] for i in items if i["id"] in picked) + + # 重复置读 + 不存在的 id → 幂等,不报错 + r = client.post( + "/api/v1/notifications/read", json={"ids": [*picked, 123456789]}, headers=_auth(token) + ) + assert r.status_code == 200 + assert r.json()["markedCount"] == 0 + assert r.json()["unreadCount"] == before - 2 + + +def test_mark_read_requires_ids_or_all(client: TestClient) -> None: + token = _login(client, "13900010006") + r = client.post("/api/v1/notifications/read", json={}, headers=_auth(token)) + assert r.status_code == 400 + r = client.post("/api/v1/notifications/read", json={"ids": []}, headers=_auth(token)) + assert r.status_code == 400 + + +def test_stores_isolated_between_users(client: TestClient) -> None: + token_a = _login(client, "13900010007") + token_b = _login(client, "13900010008") + + client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token_a)) + + r = client.get("/api/v1/notifications/unread-count", headers=_auth(token_b)) + assert r.json()["count"] > 0, "A 清零不影响 B" + + +def test_reset_store_regenerates(client: TestClient) -> None: + token = _login(client, "13900010009") + client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token)) + assert client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"] == 0 + + mock_repo.reset_store() # 联调辅助:清空后重新生成整套样例 + assert client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"] > 0 diff --git a/tests/test_push_center.py b/tests/test_push_center.py new file mode 100644 index 0000000..4f116be --- /dev/null +++ b/tests/test_push_center.py @@ -0,0 +1,393 @@ +"""厂商推送(5 家)+ 推送测试三件套。 + +覆盖:华为 Push Kit 发送链路(OAuth + messages:send payload)、send_notification 通用入口 +与 mock 模式、/push/vendors 配置状态、/push/templates 模板渲染、/push/test 的 +mock/真发/变量覆盖/站内联动/设备反查/参数校验。厂商 HTTP 全部 monkeypatch,不真发。 +""" +from __future__ import annotations + +import json + +import pytest +from fastapi.testclient import TestClient + +from app.integrations import vendor_push + +_ALL_VENDOR_SETTINGS = [key for keys in vendor_push.REQUIRED_SETTINGS.values() for key in keys] + + +class _Resp: + status_code = 200 + text = "{}" + + def __init__(self, data: dict) -> None: + self._data = data + + def json(self) -> dict: + return self._data + + +@pytest.fixture() +def _no_vendor_creds(monkeypatch) -> None: + """把 5 家厂商凭据全部清空(隔离本机 .env 里已填的真实密钥,保证用例确定性)。""" + for key in _ALL_VENDOR_SETTINGS: + monkeypatch.setattr(vendor_push.settings, key, "") + + +def _login(client: TestClient, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# integrations.vendor_push:华为链路 + 通用入口 +# --------------------------------------------------------------------------- + + +def test_huawei_auth_and_send_payload(monkeypatch) -> None: + vendor_push._token_cache.clear() + calls: list[dict] = [] + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + calls.append({"method": method, "url": url, **kwargs}) + if url == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT: + return _Resp({"access_token": "hw-access", "expires_in": 3600}) + return _Resp({"code": "80000000", "msg": "Success", "requestId": "req-1"}) + + monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_ID", "10086001") + monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_SECRET", "hw-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + data = vendor_push.send_notification( + "huawei", + "hw-token", + title="测试标题", + body="测试内容", + extras={"type": "withdraw_success", "notificationId": "90001"}, + ) + + assert data["code"] == "80000000" + # OAuth:client_id 即 AppId + assert calls[0]["url"] == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT + assert calls[0]["data"]["grant_type"] == "client_credentials" + assert calls[0]["data"]["client_id"] == "10086001" + # 发送:v1 messages:send,Bearer 鉴权,token 数组 + data 透传 extras + assert calls[1]["url"].endswith("/v1/10086001/messages:send") + assert calls[1]["headers"]["Authorization"] == "Bearer hw-access" + message = calls[1]["json"]["message"] + assert message["token"] == ["hw-token"] + assert message["android"]["notification"]["title"] == "测试标题" + assert message["android"]["notification"]["click_action"] == {"type": 3} + assert json.loads(message["data"]) == {"type": "withdraw_success", "notificationId": "90001"} + + +def test_huawei_non_success_code_raises(monkeypatch) -> None: + vendor_push._token_cache.clear() + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + if url == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT: + return _Resp({"access_token": "hw-access", "expires_in": 3600}) + return _Resp({"code": "80300007", "msg": "all tokens are invalid"}) + + monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_ID", "10086001") + monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_SECRET", "hw-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + with pytest.raises(vendor_push.VendorPushError, match="huawei push failed"): + vendor_push.send_notification("huawei", "bad-token", title="t", body="b") + + +def test_vendor_aliases_normalize() -> None: + assert vendor_push.normalize_vendor("华为") == "huawei" + assert vendor_push.normalize_vendor("HMS") == "huawei" + assert vendor_push.normalize_vendor("荣耀") == "honor" + assert vendor_push.normalize_vendor("小米") == "xiaomi" + assert vendor_push.SUPPORTED_VENDORS == {"honor", "huawei", "xiaomi", "oppo", "vivo"} + + +def test_send_notification_mock_skips_http(monkeypatch) -> None: + def _boom(*args, **kwargs): # noqa: ANN001, ANN002, ANN003 + raise AssertionError("mock 模式不应发起任何 HTTP 请求") + + monkeypatch.setattr(vendor_push.httpx, "request", _boom) + + data = vendor_push.send_notification( + "oppo", "any-token", title="标题", body="正文", extras={"type": "push_test"}, mock=True + ) + assert data == { + "mock": True, + "vendor": "oppo", + "title": "标题", + "body": "正文", + "extras": {"type": "push_test"}, + } + + +def test_send_notification_rejects_unknown_vendor() -> None: + with pytest.raises(vendor_push.VendorPushError, match="unsupported push vendor"): + vendor_push.send_notification("nokia", "t", title="a", body="b", mock=True) + with pytest.raises(vendor_push.VendorPushError, match="token is empty"): + vendor_push.send_notification("huawei", " ", title="a", body="b", mock=True) + + +def test_accessibility_wrapper_keeps_legacy_extras(monkeypatch) -> None: + """旧入口 send_accessibility_disabled 仍传 {"type":"accessibility_disabled"}(worker 兼容)。""" + captured: dict = {} + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + captured.update(method=method, url=url, **kwargs) + return _Resp({"code": 0, "result": "ok"}) + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + vendor_push.send_accessibility_disabled("xiaomi", "regid-1") + + assert json.loads(captured["data"]["payload"]) == {"type": "accessibility_disabled"} + + +# --------------------------------------------------------------------------- +# /api/v1/push 三件套 +# --------------------------------------------------------------------------- + + +def test_vendors_status_reports_missing_keys(client: TestClient, monkeypatch, _no_vendor_creds) -> None: + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + token = _login(client, "13900011001") + + r = client.get("/api/v1/push/vendors", headers=_auth(token)) + assert r.status_code == 200 + vendors = {v["vendor"]: v for v in r.json()["vendors"]} + assert list(vendors) == ["honor", "huawei", "xiaomi", "oppo", "vivo"] + + assert vendors["xiaomi"]["configured"] is True + assert vendors["xiaomi"]["missingKeys"] == [] + assert vendors["huawei"]["configured"] is False + assert vendors["huawei"]["missingKeys"] == ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"] + assert vendors["honor"]["label"] == "荣耀" + assert vendors["vivo"]["missingKeys"] == [ + "VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET", + ] + + +def test_templates_render_all_13_types(client: TestClient) -> None: + token = _login(client, "13900011002") + r = client.get("/api/v1/push/templates", headers=_auth(token)) + assert r.status_code == 200 + templates = r.json()["templates"] + assert len(templates) == 13 + + by_type = {t["type"]: t for t in templates} + ws = by_type["withdraw_success"] + assert ws["pushTitle"] == "提现到账提醒" + assert ws["pushBodySample"] == "¥0.50已存入您的微信钱包,点击查看到账详情" + assert ws["variables"] == ["amount"] + + expiring = by_type["reward_expiring"] + assert "86金币" in expiring["pushBodySample"] + assert "{coins}" in expiring["pushBodyTemplate"] + assert expiring["sampleVars"]["cash"] == "12.80" + + # 权限类标题按类型写死功能名 + assert by_type["perm_overlay"]["pushTitle"] == "检测到您的比价按钮已失效" + + +def test_push_test_mock_renders_template(client: TestClient, _no_vendor_creds) -> None: + token = _login(client, "13900011003") + r = client.post( + "/api/v1/push/test", + json={"vendor": "华为", "pushToken": "hw-token-1", "type": "withdraw_success"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is True + assert body["mock"] is True + assert body["vendor"] == "huawei" # 中文别名已归一化 + assert body["title"] == "提现到账提醒" + assert body["body"] == "¥0.50已存入您的微信钱包,点击查看到账详情" + assert body["extras"] == {"type": "withdraw_success"} + assert body["missingKeys"] == ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"] + assert body["vendorResponse"] is None + + +def test_push_test_vars_override(client: TestClient, _no_vendor_creds) -> None: + token = _login(client, "13900011004") + r = client.post( + "/api/v1/push/test", + json={ + "vendor": "xiaomi", + "pushToken": "xm-1", + "type": "invite_order_reward", + "vars": {"nickname": "小王", "amount": "6.66"}, + }, + headers=_auth(token), + ) + assert r.status_code == 200 + assert r.json()["body"] == "您的好友「小王」完成首次下单,6.66元现金已到账" + + +def test_push_test_generic_copy_without_type(client: TestClient, _no_vendor_creds) -> None: + token = _login(client, "13900011005") + r = client.post( + "/api/v1/push/test", + json={"vendor": "oppo", "pushToken": "op-1"}, + headers=_auth(token), + ) + assert r.status_code == 200 + body = r.json() + assert body["extras"]["type"] == "push_test" + assert "OPPO" in body["body"] + + +def test_push_test_create_notification_links_message_center( + client: TestClient, _no_vendor_creds +) -> None: + token = _login(client, "13900011006") + before = client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"] + + r = client.post( + "/api/v1/push/test", + json={ + "vendor": "vivo", + "pushToken": "vv-1", + "type": "feedback_reward", + "createNotification": True, + }, + headers=_auth(token), + ) + assert r.status_code == 200 + body = r.json() + nid = body["notificationId"] + assert isinstance(nid, int) + assert body["extras"]["notificationId"] == str(nid) + assert body["extras"]["type"] == "feedback_reward" + assert body["extras"]["feedbackId"] # 业务参数一并带上,客户端可直达反馈详情 + + # 站内多了一条未读;按 push extras 的 id 置读 → 闭环 + after = client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"] + assert after == before + 1 + r = client.post("/api/v1/notifications/read", json={"ids": [nid]}, headers=_auth(token)) + assert r.json()["markedCount"] == 1 + + +def test_push_test_real_send_requires_credentials(client: TestClient, _no_vendor_creds) -> None: + token = _login(client, "13900011007") + r = client.post( + "/api/v1/push/test", + json={"vendor": "huawei", "pushToken": "hw-1", "mock": False}, + headers=_auth(token), + ) + assert r.status_code == 400 + assert "HUAWEI_PUSH_APP_ID" in r.json()["detail"] + + +def test_push_test_real_send_xiaomi(client: TestClient, monkeypatch, _no_vendor_creds) -> None: + captured: dict = {} + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + captured.update(method=method, url=url, **kwargs) + return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-real"}}) + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + token = _login(client, "13900011008") + + r = client.post( + "/api/v1/push/test", + json={ + "vendor": "xiaomi", + "pushToken": "xm-regid-9", + "type": "report_approved", + "mock": False, + }, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["mock"] is False + assert body["missingKeys"] == [] + assert body["vendorResponse"]["data"]["id"] == "xm-real" + assert captured["data"]["registration_id"] == "xm-regid-9" + assert captured["data"]["title"] == "爆料审核通过" + assert "蜀大侠火锅" in captured["data"]["description"] + assert json.loads(captured["data"]["payload"]) == {"type": "report_approved"} + + +def test_push_test_real_send_vendor_error_maps_502( + client: TestClient, monkeypatch, _no_vendor_creds +) -> None: + def _fake_request(method, url, **kwargs): # noqa: ANN001 + return _Resp({"code": 500, "result": "error", "reason": "invalid regid"}) + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + token = _login(client, "13900011009") + + r = client.post( + "/api/v1/push/test", + json={"vendor": "xiaomi", "pushToken": "bad", "mock": False}, + headers=_auth(token), + ) + assert r.status_code == 502 + assert "厂商推送失败" in r.json()["detail"] + + +def test_push_test_resolves_token_from_registered_device( + client: TestClient, _no_vendor_creds +) -> None: + token = _login(client, "13900011010") + r = client.post( + "/api/v1/device/register", + json={"device_id": "dev-push-center-1", "push_vendor": "honor", "push_token": "honor-t1"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + + r = client.post( + "/api/v1/push/test", + json={"deviceId": "dev-push-center-1", "type": "perm_accessibility"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["vendor"] == "honor" + assert body["title"] == "检测到您的比价功能已失效" + + +def test_push_test_validation_errors(client: TestClient, _no_vendor_creds) -> None: + token = _login(client, "13900011011") + + # 未知厂商 + r = client.post( + "/api/v1/push/test", + json={"vendor": "nokia", "pushToken": "t"}, + headers=_auth(token), + ) + assert r.status_code == 400 + + # 未知类型 + r = client.post( + "/api/v1/push/test", + json={"vendor": "xiaomi", "pushToken": "t", "type": "bogus"}, + headers=_auth(token), + ) + assert r.status_code == 400 + assert "unknown notification type" in r.json()["detail"] + + # 真发但没有 token 可用 + r = client.post( + "/api/v1/push/test", + json={"vendor": "xiaomi", "mock": False}, + headers=_auth(token), + ) + assert r.status_code == 409