"""厂商推送 测试/联调 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, )