a8ac5dc0c7
- GET /api/v1/notifications(分页,时间倒序不分组)/unread-count /read(ids|all),
对外 camelCase(PRD 前端契约);数据为内存 mock(13 类型全覆盖,重启复位),
接真实数据只换 repositories/notification_mock.py
- vendor_push 补华为 Push Kit(OAuth+messages:send),抽通用 send_notification
(任意文案+extras+mock 模式),send_accessibility_disabled 改薄封装行为不变
- 新增 /api/v1/push/{vendors,templates,test}:凭据状态/13 类 PRD 文案模板/测试发送
(默认 mock,mock=false 真发,可联动插站内 mock 消息闭环验证已读)
- .env.example 补 HUAWEI_* 键;docs/api 新增 notifications.md + push-vendor-test.md
- alembic merge 1a924c274fce 收拢 direct_vendor_push_fields/feedback_type_reply
双 head,恢复 upgrade head 可用
- tests: test_notifications + test_push_center 共 35 例
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
187 lines
7.9 KiB
Python
187 lines
7.9 KiB
Python
"""厂商推送 测试/联调 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,
|
|
)
|