From 2236a8b3ee3cdeba8dec3a1f3948980e89b1deb3 Mon Sep 17 00:00:00 2001 From: lowmaster-chen <1119780489@qq.com> Date: Thu, 2 Jul 2026 23:01:20 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=9F=BA=E4=BA=8E=20main=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=90=84=E5=8E=82=E5=95=86=E7=9B=B4=E6=8E=A8=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动:新增厂商推送配置、设备 push_vendor/push_token 字段、device push-test 接口、心跳超时厂商直推发送逻辑和对应测试。 验证:python -m pytest tests/test_device_push.py tests/test_auth.py tests/test_health.py 通过。 --- .env.example | 38 +- alembic/versions/direct_vendor_push_fields.py | 30 ++ app/admin/schemas/device.py | 4 +- app/api/v1/device.py | 97 ++++- app/core/config.py | 39 +- app/core/heartbeat_monitor_worker.py | 49 ++- app/integrations/vendor_push.py | 380 ++++++++++++++++++ app/models/device.py | 14 +- app/repositories/device.py | 50 ++- app/schemas/device.py | 23 +- tests/test_device_push.py | 317 +++++++++++++++ 11 files changed, 1017 insertions(+), 24 deletions(-) create mode 100644 alembic/versions/direct_vendor_push_fields.py create mode 100644 app/integrations/vendor_push.py create mode 100644 tests/test_device_push.py diff --git a/.env.example b/.env.example index 110e939..96abab9 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,43 @@ 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 -# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)===== +# ===== 厂商直推(无障碍保护存活告警)===== +# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。 +ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia +PUSH_REQUEST_TIMEOUT_SEC=15 +PUSH_TIME_TO_LIVE_SEC=86400 + +HONOR_PUSH_APP_ID= +HONOR_PUSH_CLIENT_ID= +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 + +VIVO_PUSH_APP_ID= +VIVO_PUSH_APP_KEY= +VIVO_PUSH_APP_SECRET= +VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth +VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send +# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。 +VIVO_PUSH_MODE=1 +VIVO_PUSH_NOTIFY_TYPE=4 +VIVO_PUSH_CATEGORY=DEVICE_REMINDER + +XIAOMI_PUSH_APP_SECRET= +XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid +XIAOMI_PUSH_CHANNEL_ID= +XIAOMI_PUSH_TEMPLATE_ID= +XIAOMI_PUSH_TEMPLATE_TITLE= +XIAOMI_PUSH_TEMPLATE_DESCRIPTION= +# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"} +XIAOMI_PUSH_TEMPLATE_PARAM_JSON= + +OPPO_PUSH_APP_KEY= +OPPO_PUSH_MASTER_SECRET= +OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth +OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast + +# ===== 无障碍保护存活监控(推送 + pull 后置兜底)===== HEARTBEAT_MONITOR_ENABLED=true HEARTBEAT_TIMEOUT_MINUTES=60 HEARTBEAT_SCAN_INTERVAL_SEC=60 diff --git a/alembic/versions/direct_vendor_push_fields.py b/alembic/versions/direct_vendor_push_fields.py new file mode 100644 index 0000000..ec33ed2 --- /dev/null +++ b/alembic/versions/direct_vendor_push_fields.py @@ -0,0 +1,30 @@ +"""add direct vendor push fields + +Revision ID: direct_vendor_push_fields +Revises: jd_cps_order_fields +Create Date: 2026-07-01 16:30:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "direct_vendor_push_fields" +down_revision = "jd_cps_order_fields" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("device_liveness") as batch_op: + batch_op.add_column(sa.Column("push_vendor", sa.String(length=32), nullable=True)) + batch_op.add_column(sa.Column("push_token", sa.String(length=256), nullable=True)) + batch_op.create_index("ix_device_liveness_push_vendor", ["push_vendor"]) + + +def downgrade() -> None: + with op.batch_alter_table("device_liveness") as batch_op: + batch_op.drop_index("ix_device_liveness_push_vendor") + batch_op.drop_column("push_token") + batch_op.drop_column("push_vendor") diff --git a/app/admin/schemas/device.py b/app/admin/schemas/device.py index da3fe60..68568b9 100644 --- a/app/admin/schemas/device.py +++ b/app/admin/schemas/device.py @@ -24,7 +24,9 @@ class DeviceLivenessItem(BaseModel): device_model: str | None = None # 由 device_id 解析(device_<机型>_);非 DB 列 platform: str app_version: str | None = None - registration_id: str | None = None # 非空 = 拿到极光 token、可推送 + registration_id: str | None = None # 旧极光字段,仅兼容历史数据 + push_vendor: str | None = None + push_token: str | None = None ever_protected: bool # 是否开过无障碍(=该设备对功能有意义) first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null) diff --git a/app/api/v1/device.py b/app/api/v1/device.py index 56a5a85..893c6c5 100644 --- a/app/api/v1/device.py +++ b/app/api/v1/device.py @@ -1,19 +1,22 @@ """设备注册 / 心跳 endpoint(无障碍保护存活检测)。 路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。 - POST /register 注册设备 / 更新 registration_id(App 前台、拿到 push token 时调) + POST /register 注册设备 / 更新厂商 push token(App 前台、拿到 push token 时调) POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活) + POST /push-test 开发验收:延迟发送厂商通道测试推送 -后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警。 +后端 heartbeat_monitor_worker 据此发现心跳超时的设备并厂商直推告警。 见 spec: spec/accessibility-liveness-push.md。 """ from __future__ import annotations import logging +import time -from fastapi import APIRouter +from fastapi import APIRouter, BackgroundTasks, HTTPException, status from app.api.deps import CurrentUser, DbSession +from app.integrations import vendor_push from app.repositories import device as device_repo from app.schemas.device import ( DeviceOut, @@ -22,6 +25,8 @@ from app.schemas.device import ( LivenessAckRequest, LivenessOut, OkResponse, + PushTestOut, + PushTestRequest, ) logger = logging.getLogger("shagua.device") @@ -29,6 +34,37 @@ logger = logging.getLogger("shagua.device") router = APIRouter(prefix="/api/v1/device", tags=["device"]) +def _send_push_test_after_delay( + push_vendor: str, + push_token: str, + delay_seconds: int, + user_id: int, + device_id: str, +) -> None: + if delay_seconds > 0: + time.sleep(delay_seconds) + try: + vendor_push.send_accessibility_disabled( + push_vendor, + push_token, + title="测试推送", + alert="这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。", + ) + logger.info( + "push test sent user_id=%d device_id=%s delay=%ds", + user_id, + device_id, + delay_seconds, + ) + except vendor_push.VendorPushError as e: + logger.warning( + "push test failed user_id=%d device_id=%s error=%s", + user_id, + device_id, + e, + ) + + @router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token") def register_device( req: DeviceRegisterRequest, @@ -40,13 +76,17 @@ def register_device( user_id=user.id, device_id=req.device_id, registration_id=req.registration_id, + push_vendor=req.push_vendor, + push_token=req.push_token, platform=req.platform, app_version=req.app_version, ) logger.info( - "device register user_id=%d device_id=%s reg=%s", + "device register user_id=%d device_id=%s vendor=%s token=%s legacy_reg=%s", user.id, req.device_id, + req.push_vendor, + bool(req.push_token), bool(req.registration_id), ) return DeviceOut.model_validate(device) @@ -64,10 +104,59 @@ def report_heartbeat( device_id=req.device_id, accessibility_enabled=req.accessibility_enabled, registration_id=req.registration_id, + push_vendor=req.push_vendor, + push_token=req.push_token, ) return OkResponse() +@router.post("/push-test", response_model=PushTestOut, summary="延迟发送厂商通道测试推送") +def request_push_test( + req: PushTestRequest, + background_tasks: BackgroundTasks, + user: CurrentUser, + db: DbSession, +) -> PushTestOut: + """开发验收用:App 内点一次,服务端延迟发厂商直推,验证离线通道。""" + push_vendor = req.push_vendor.strip() if req.push_vendor else None + push_token = req.push_token.strip() if req.push_token else None + if push_vendor and push_token: + device_repo.register_or_update( + db, + user_id=user.id, + device_id=req.device_id, + registration_id=req.registration_id, + push_vendor=push_vendor, + push_token=push_token, + ) + else: + device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id) + push_vendor = device.push_vendor if device is not None else None + push_token = device.push_token if device is not None else None + + if not push_vendor or not push_token: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="push vendor token not ready", + ) + + background_tasks.add_task( + _send_push_test_after_delay, + push_vendor, + push_token, + req.delay_seconds, + user.id, + req.device_id, + ) + logger.info( + "push test scheduled user_id=%d device_id=%s delay=%ds", + user.id, + req.device_id, + req.delay_seconds, + ) + return PushTestOut(delay_seconds=req.delay_seconds, has_push_token=True) + + @router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)") def get_liveness( device_id: str, diff --git a/app/core/config.py b/app/core/config.py index 3e27af5..e98d5e7 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -66,7 +66,44 @@ class Settings(BaseSettings): JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify" JG_REQUEST_TIMEOUT_SEC: int = 15 - # 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送) + # ===== 厂商直推(无障碍保护存活告警)===== + ANDROID_PACKAGE_NAME: str = "com.jishisongfu.shaguabijia" + PUSH_REQUEST_TIMEOUT_SEC: int = 15 + PUSH_TIME_TO_LIVE_SEC: int = 86400 + + HONOR_PUSH_APP_ID: str = "" + HONOR_PUSH_CLIENT_ID: str = "" + HONOR_PUSH_CLIENT_SECRET: str = "" + HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token" + HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = ( + "https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage" + ) + + VIVO_PUSH_APP_ID: str = "" + VIVO_PUSH_APP_KEY: str = "" + VIVO_PUSH_APP_SECRET: str = "" + VIVO_PUSH_AUTH_ENDPOINT: str = "https://api-push.vivo.com.cn/message/auth" + VIVO_PUSH_SEND_ENDPOINT: str = "https://api-push.vivo.com.cn/message/send" + VIVO_PUSH_MODE: int = 1 # 0=正式推送,1=测试推送(未上架 vivo 时用) + VIVO_PUSH_NOTIFY_TYPE: int = 4 # 1=无,2=响铃,3=振动,4=响铃+振动 + VIVO_PUSH_CATEGORY: str = "DEVICE_REMINDER" + + XIAOMI_PUSH_APP_SECRET: str = "" + XIAOMI_PUSH_SEND_ENDPOINT: str = "https://api.xmpush.xiaomi.com/v3/message/regid" + XIAOMI_PUSH_CHANNEL_ID: str = "" + XIAOMI_PUSH_TEMPLATE_ID: str = "" + XIAOMI_PUSH_TEMPLATE_TITLE: str = "" + XIAOMI_PUSH_TEMPLATE_DESCRIPTION: str = "" + XIAOMI_PUSH_TEMPLATE_PARAM_JSON: str = "" + + OPPO_PUSH_APP_KEY: str = "" + OPPO_PUSH_MASTER_SECRET: str = "" + OPPO_PUSH_AUTH_ENDPOINT: str = "https://api.push.oppomobile.com/server/v1/auth" + OPPO_PUSH_SEND_ENDPOINT: str = ( + "https://api.push.oppomobile.com/server/v1/message/notification/unicast" + ) + + # 无障碍保护存活监控后台任务(推送 + pull 后置兜底) HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关 HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀) HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期 diff --git a/app/core/heartbeat_monitor_worker.py b/app/core/heartbeat_monitor_worker.py index 6072d26..be27124 100644 --- a/app/core/heartbeat_monitor_worker.py +++ b/app/core/heartbeat_monitor_worker.py @@ -1,7 +1,7 @@ """无障碍保护存活监控后台任务。 周期扫描「曾经保护过、当前 alive、心跳超时」的设备 = App 被彻底杀掉/无障碍已停(心跳断了), -**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机 +**命中即在服务器终端打印告警并尝试厂商直推**;并把状态机 推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)。 结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)。 @@ -22,6 +22,7 @@ from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings from app.db.session import SessionLocal +from app.integrations import vendor_push from app.repositories import device as device_repo logger = logging.getLogger("shagua.heartbeat_monitor") @@ -71,32 +72,66 @@ def _silent_seconds(last: datetime | None) -> int | None: """距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。""" if last is None: return None - ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow() + ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow() # noqa: UP017 return int((ref - last).total_seconds()) def _scan_once(timeout_minutes: int) -> dict: - """扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送。 + """扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备并召回。 - 本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端, - 并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)。 + 有 push_vendor + push_token 时先发厂商直推,无 token 或推送失败时仍置 + kill_alert_pending,客户端下次进 App 继续走后置提醒兜底。 """ notified = 0 + pushed = 0 + push_failed = 0 with SessionLocal() as db: overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes) for device in overdue: silent = _silent_seconds(device.last_heartbeat_at) logger.warning( "[掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)" - " → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】", + " → 判定 App 已被杀/无障碍已停。", device.user_id, device.device_id, silent if silent is not None else "?", timeout_minutes, ) + if device.push_vendor and device.push_token: + try: + vendor_push.send_accessibility_disabled( + device.push_vendor, + device.push_token, + ) + pushed += 1 + logger.info( + "[掉线检测] push sent user_id=%s device_id=%s vendor=%s", + device.user_id, + device.device_id, + device.push_vendor, + ) + except vendor_push.VendorPushError as e: + push_failed += 1 + logger.warning( + "[掉线检测] push failed user_id=%s device_id=%s error=%s", + device.user_id, + device.device_id, + e, + ) + else: + logger.info( + "[掉线检测] device has no push vendor/token, skip push user_id=%s device_id=%s", + device.user_id, + device.device_id, + ) device_repo.mark_notified(db, device_id_pk=device.id) notified += 1 - return {"checked": len(overdue), "notified": notified} + return { + "checked": len(overdue), + "notified": notified, + "pushed": pushed, + "push_failed": push_failed, + } async def _run_loop() -> None: diff --git a/app/integrations/vendor_push.py b/app/integrations/vendor_push.py new file mode 100644 index 0000000..92917e3 --- /dev/null +++ b/app/integrations/vendor_push.py @@ -0,0 +1,380 @@ +"""厂商直推集成。 + +服务端不再经由 JPush Push API 发送无障碍召回通知,而是按客户端上报的 +push_vendor + push_token 分发到各手机厂商的服务端 API。 +""" +from __future__ import annotations + +import hashlib +import json +import logging +import time +import uuid +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import httpx + +from app.core.config import settings + +logger = logging.getLogger("shagua.vendor_push") + +TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled" +SUPPORTED_VENDORS = frozenset({"honor", "vivo", "xiaomi", "oppo"}) + + +class VendorPushError(Exception): + """厂商推送调用失败。""" + + +@dataclass +class _CachedToken: + value: str + expires_at: float + + +_token_cache: dict[str, _CachedToken] = {} + + +def normalize_vendor(push_vendor: str | None) -> str | None: + if not push_vendor: + return None + vendor = push_vendor.strip().lower() + aliases = { + "hihonor": "honor", + "荣耀": "honor", + "mi": "xiaomi", + "小米": "xiaomi", + "oneplus": "oppo", + "realme": "oppo", + } + return aliases.get(vendor, vendor) + + +def send_accessibility_disabled( + push_vendor: str, + push_token: str, + *, + 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} + + +def _require(value: str, name: str) -> str: + if not value: + raise VendorPushError(f"{name} not configured") + return value + + +def _request_json( + method: str, + url: str, + *, + expected_status: tuple[int, ...] = (200,), + **kwargs: Any, +) -> dict[str, Any]: + try: + resp = httpx.request( + method, + url, + timeout=settings.PUSH_REQUEST_TIMEOUT_SEC, + **kwargs, + ) + except httpx.HTTPError as e: + raise VendorPushError(f"push http error: {e}") from e + + if resp.status_code not in expected_status: + logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500]) + raise VendorPushError(f"push http {resp.status_code}") + try: + return resp.json() + except ValueError as e: + raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e + + +def _request_form( + method: str, + url: str, + *, + expected_status: tuple[int, ...] = (200,), + **kwargs: Any, +) -> dict[str, Any]: + try: + resp = httpx.request( + method, + url, + timeout=settings.PUSH_REQUEST_TIMEOUT_SEC, + **kwargs, + ) + except httpx.HTTPError as e: + raise VendorPushError(f"push http error: {e}") from e + + if resp.status_code not in expected_status: + logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500]) + raise VendorPushError(f"push http {resp.status_code}") + try: + return resp.json() + except ValueError as e: + raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e + + +def _cache_get(key: str) -> str | None: + cached = _token_cache.get(key) + if cached and cached.expires_at > time.time() + 60: + return cached.value + return None + + +def _cache_put(key: str, value: str, expires_in: int | float | None) -> str: + ttl = int(expires_in or 3600) + _token_cache[key] = _CachedToken(value=value, expires_at=time.time() + max(60, ttl - 60)) + return value + + +def _honor_access_token() -> str: + cache_key = "honor" + cached = _cache_get(cache_key) + if cached: + return cached + client_id = _require(settings.HONOR_PUSH_CLIENT_ID, "HONOR_PUSH_CLIENT_ID") + client_secret = _require(settings.HONOR_PUSH_CLIENT_SECRET, "HONOR_PUSH_CLIENT_SECRET") + data = _request_form( + "POST", + settings.HONOR_PUSH_TOKEN_ENDPOINT, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + token = data.get("access_token") + if not token: + raise VendorPushError(f"honor auth failed: {data}") + return _cache_put(cache_key, str(token), data.get("expires_in")) + + +def _send_honor(token: str, title: str, alert: 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}, + "android": { + "ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s", + "targetUserType": 1, + "notification": { + "title": title, + "body": alert, + "clickAction": {"type": 3}, + "importance": "NORMAL", + }, + }, + "token": [token], + } + data = _request_json( + "POST", + settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), + json=payload, + headers={ + "Content-Type": "application/json; charset=UTF-8", + "Authorization": f"Bearer {access_token}", + "timestamp": str(int(time.time() * 1000)), + }, + ) + code = data.get("code") + if code is not None and int(code) != 200: + raise VendorPushError(f"honor push failed: {data}") + return data + + +def _vivo_auth_token() -> str: + cache_key = "vivo" + cached = _cache_get(cache_key) + if cached: + return cached + app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID") + app_key = _require(settings.VIVO_PUSH_APP_KEY, "VIVO_PUSH_APP_KEY") + app_secret = _require(settings.VIVO_PUSH_APP_SECRET, "VIVO_PUSH_APP_SECRET") + timestamp = str(int(time.time() * 1000)) + sign = hashlib.md5(f"{app_id}{app_key}{timestamp}{app_secret}".encode()).hexdigest() # noqa: S324 + data = _request_json( + "POST", + settings.VIVO_PUSH_AUTH_ENDPOINT, + json={ + "appId": app_id, + "appKey": app_key, + "timestamp": timestamp, + "sign": sign, + }, + headers={"Content-Type": "application/json"}, + ) + if int(data.get("result", -1)) != 0: + raise VendorPushError(f"vivo auth failed: {data}") + token = data.get("authToken") + if not token: + raise VendorPushError(f"vivo auth missing authToken: {data}") + return _cache_put(cache_key, str(token), 24 * 3600) + + +def _send_vivo(token: str, title: str, alert: 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] = { + "appId": app_id, + "regId": token, + "notifyType": settings.VIVO_PUSH_NOTIFY_TYPE, + "title": title, + "content": alert, + "timeToLive": settings.PUSH_TIME_TO_LIVE_SEC, + "skipType": 1, + "requestId": uuid.uuid4().hex, + "pushMode": settings.VIVO_PUSH_MODE, + "clientCustomMap": _extras(), + } + if settings.VIVO_PUSH_CATEGORY: + body["category"] = settings.VIVO_PUSH_CATEGORY + data = _request_json( + "POST", + settings.VIVO_PUSH_SEND_ENDPOINT, + json=body, + headers={ + "Content-Type": "application/json", + "authToken": auth_token, + }, + ) + if int(data.get("result", -1)) != 0: + raise VendorPushError(f"vivo push failed: {data}") + return data + + +def _send_xiaomi(token: str, title: str, alert: 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 = { + "registration_id": token, + "restricted_package_name": settings.ANDROID_PACKAGE_NAME, + "title": message_title, + "description": message_description, + "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() + if settings.XIAOMI_PUSH_TEMPLATE_ID: + body["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) + data = _request_form( + "POST", + settings.XIAOMI_PUSH_SEND_ENDPOINT, + data=body, + headers={"Authorization": f"key={app_secret}"}, + ) + code = data.get("code") + if code not in (0, "0", None): + raise VendorPushError(f"xiaomi push failed: {data}") + if str(data.get("result", "ok")).lower() not in ("ok", "success"): + raise VendorPushError(f"xiaomi push failed: {data}") + return data + + +def _xiaomi_template_param(title: str, alert: str) -> str: + rendered = ( + settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON + .replace("{title}", title) + .replace("{alert}", alert) + ) + try: + payload = json.loads(rendered) + except ValueError as e: + raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON invalid json") from e + if not isinstance(payload, dict): + raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON must be a json object") + for key, value in payload.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise VendorPushError("xiaomi template params must be string key-value pairs") + if not value.strip() or len(value) > 128: + raise VendorPushError("xiaomi template param value length must be 1-128") + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +def _oppo_auth_token() -> str: + cache_key = "oppo" + cached = _cache_get(cache_key) + if cached: + return cached + app_key = _require(settings.OPPO_PUSH_APP_KEY, "OPPO_PUSH_APP_KEY") + master_secret = _require(settings.OPPO_PUSH_MASTER_SECRET, "OPPO_PUSH_MASTER_SECRET") + timestamp = str(int(time.time() * 1000)) + sign = hashlib.sha256(f"{app_key}{timestamp}{master_secret}".encode()).hexdigest() + data = _request_form( + "POST", + settings.OPPO_PUSH_AUTH_ENDPOINT, + data={ + "app_key": app_key, + "timestamp": timestamp, + "sign": sign, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if int(data.get("code", -1)) != 0: + raise VendorPushError(f"oppo auth failed: {data}") + token = (data.get("data") or {}).get("auth_token") or data.get("auth_token") + if not token: + raise VendorPushError(f"oppo auth missing auth_token: {data}") + return _cache_put(cache_key, str(token), 24 * 3600) + + +def _send_oppo(token: str, title: str, alert: 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}", + "title": title, + "content": alert, + "click_action_type": 0, + "off_line": True, + "off_line_ttl": ttl_hours, + "action_parameters": json.dumps(_extras(), ensure_ascii=False), + }, + } + data = _request_form( + "POST", + settings.OPPO_PUSH_SEND_ENDPOINT, + data={ + "auth_token": auth_token, + "message": json.dumps(message, ensure_ascii=False), + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if int(data.get("code", -1)) != 0: + raise VendorPushError(f"oppo push failed: {data}") + return data diff --git a/app/models/device.py b/app/models/device.py index e1d1261..324e176 100644 --- a/app/models/device.py +++ b/app/models/device.py @@ -1,9 +1,9 @@ -"""设备表(无障碍保护存活检测 + 极光推送)。 +"""设备表(无障碍保护存活检测 + 厂商直推)。 每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。 客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报 -registration_id(极光推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、 -现在心跳超时」的设备,通过极光推送提醒用户重开无障碍。 +push_vendor + push_token(厂商推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、 +现在心跳超时」的设备,通过厂商直推提醒用户重开无障碍。 liveness_state 状态机(防刷屏,一次掉线只推一条): unknown → alive(收到 service 心跳)→ silent/notified(扫描发现超时并已推送) @@ -30,7 +30,7 @@ from app.db.base import Base class DeviceLiveness(Base): # 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态** - # (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。 + # (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 厂商推送目标),故名 device_liveness。 __tablename__ = "device_liveness" __table_args__ = ( UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"), @@ -42,8 +42,12 @@ class DeviceLiveness(Base): ) # 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34) device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False) - # 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发) + # 旧极光推送 registration id,仅为兼容历史客户端/数据保留;新链路使用 push_vendor + push_token。 registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 厂商推送类型:honor/vivo/xiaomi/oppo 等;客户端按实际 SDK token 来源上报。 + push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。 + push_token: Mapped[str | None] = mapped_column(String(256), nullable=True) platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android") app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) diff --git a/app/repositories/device.py b/app/repositories/device.py index c3e9047..31b84d9 100644 --- a/app/repositories/device.py +++ b/app/repositories/device.py @@ -21,17 +21,23 @@ def register_or_update( *, user_id: int, device_id: str, - registration_id: str | None, + registration_id: str | None = None, + push_vendor: str | None = None, + push_token: str | None = None, platform: str = "android", app_version: str | None = None, ) -> DeviceLiveness: - """注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。""" + """注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)。""" + normalized_vendor = _normalize_push_vendor(push_vendor) + normalized_token = push_token.strip() if push_token else None device = _get(db, user_id=user_id, device_id=device_id) if device is None: device = DeviceLiveness( user_id=user_id, device_id=device_id, registration_id=registration_id, + push_vendor=normalized_vendor, + push_token=normalized_token, platform=platform or "android", app_version=app_version, ) @@ -39,6 +45,10 @@ def register_or_update( else: if registration_id: device.registration_id = registration_id + if normalized_vendor: + device.push_vendor = normalized_vendor + if normalized_token: + device.push_token = normalized_token if platform: device.platform = platform if app_version: @@ -54,7 +64,9 @@ def touch_heartbeat( user_id: int, device_id: str, accessibility_enabled: bool, - registration_id: str | None, + registration_id: str | None = None, + push_vendor: str | None = None, + push_token: str | None = None, ) -> DeviceLiveness: """处理一次心跳(心跳也能自注册)。 @@ -69,6 +81,12 @@ def touch_heartbeat( if registration_id: device.registration_id = registration_id + normalized_vendor = _normalize_push_vendor(push_vendor) + normalized_token = push_token.strip() if push_token else None + if normalized_vendor: + device.push_vendor = normalized_vendor + if normalized_token: + device.push_token = normalized_token device.last_report_protection_on = accessibility_enabled if accessibility_enabled: @@ -87,7 +105,7 @@ def touch_heartbeat( def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]: """掉线设备:曾经保护过、当前 alive、心跳超时。 - 本期只做终端打印检测、不推送 → 不再要求有 registration_id(没接极光 token 的设备也要检出)。 + 即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒。 """ cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes) stmt = select(DeviceLiveness).where( @@ -124,3 +142,27 @@ def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None: if device is not None and device.kill_alert_pending: device.kill_alert_pending = False db.commit() + + +def has_push_target(device: DeviceLiveness | None) -> bool: + """是否已有厂商直推所需的 vendor + token。""" + return bool(device and device.push_vendor and device.push_token) + + +def _normalize_push_vendor(push_vendor: str | None) -> str | None: + if not push_vendor: + return None + vendor = push_vendor.strip().lower() + aliases = { + "honor": "honor", + "hihonor": "honor", + "荣耀": "honor", + "vivo": "vivo", + "xiaomi": "xiaomi", + "mi": "xiaomi", + "小米": "xiaomi", + "oppo": "oppo", + "oneplus": "oppo", + "realme": "oppo", + } + return aliases.get(vendor, vendor) diff --git a/app/schemas/device.py b/app/schemas/device.py index dd7a7c4..3f8cda1 100644 --- a/app/schemas/device.py +++ b/app/schemas/device.py @@ -3,12 +3,15 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class DeviceRegisterRequest(BaseModel): device_id: str + # registration_id 为旧极光字段,新推送链路统一使用 push_vendor + push_token。 registration_id: str | None = None + push_vendor: str | None = None + push_token: str | None = None platform: str = "android" app_version: str | None = None @@ -18,6 +21,8 @@ class HeartbeatRequest(BaseModel): source: str = "service" # service | app accessibility_enabled: bool = True registration_id: str | None = None + push_vendor: str | None = None + push_token: str | None = None class DeviceOut(BaseModel): @@ -26,6 +31,8 @@ class DeviceOut(BaseModel): id: int device_id: str registration_id: str | None + push_vendor: str | None + push_token: str | None ever_protected: bool liveness_state: str last_heartbeat_at: datetime | None @@ -46,3 +53,17 @@ class LivenessOut(BaseModel): class LivenessAckRequest(BaseModel): device_id: str + + +class PushTestRequest(BaseModel): + device_id: str + delay_seconds: int = Field(default=10, ge=0, le=60) + push_vendor: str | None = None + push_token: str | None = None + registration_id: str | None = None + + +class PushTestOut(BaseModel): + ok: bool = True + delay_seconds: int + has_push_token: bool diff --git a/tests/test_device_push.py b/tests/test_device_push.py new file mode 100644 index 0000000..a35ec24 --- /dev/null +++ b/tests/test_device_push.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +from fastapi.testclient import TestClient + +from app.api.v1 import device as device_api +from app.core import heartbeat_monitor_worker +from app.db.session import SessionLocal +from app.integrations import vendor_push +from app.models.device import DeviceLiveness +from app.repositories import user as user_repo + + +class _Resp: + status_code = 200 + text = "{}" + + def __init__(self, data: dict) -> None: + self._data = data + + def json(self) -> dict: + return self._data + + +def test_xiaomi_accessibility_payload(monkeypatch) -> 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-msg"}}) + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_CHANNEL_ID", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_ID", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", "") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + data = vendor_push.send_accessibility_disabled("xiaomi", "xm-regid") + + assert data["data"]["id"] == "xm-msg" + assert captured["method"] == "POST" + assert captured["url"] == vendor_push.settings.XIAOMI_PUSH_SEND_ENDPOINT + assert captured["headers"]["Authorization"] == "key=xiaomi-secret" + body = captured["data"] + assert body["registration_id"] == "xm-regid" + assert body["restricted_package_name"] == "com.jishisongfu.shaguabijia" + assert json.loads(body["payload"]) == {"type": "accessibility_disabled"} + assert "extra.channel_id" not in body + + +def test_xiaomi_payload_with_channel_and_template(monkeypatch) -> 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-msg"}}) + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_CHANNEL_ID", "130") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_ID", "1001") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "{$app_name$}提醒") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "{$content$}") + monkeypatch.setattr( + vendor_push.settings, + "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", + '{"app_name":"傻瓜比价","content":"{alert}"}', + ) + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + vendor_push.send_accessibility_disabled( + "xiaomi", + "xm-regid", + title="测试标题", + alert="测试内容", + ) + + body = captured["data"] + assert body["title"] == "{$app_name$}提醒" + assert body["description"] == "{$content$}" + assert body["extra.channel_id"] == "130" + assert body["extra.template_id"] == "1001" + assert body["extra.template_param"] == '{"app_name":"傻瓜比价","content":"测试内容"}' + + +def test_vivo_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.VIVO_PUSH_AUTH_ENDPOINT: + return _Resp({"result": 0, "authToken": "vivo-auth"}) + return _Resp({"result": 0, "taskId": "vivo-task"}) + + monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_ID", "106072775") + monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_KEY", "vivo-key") + monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_SECRET", "vivo-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + data = vendor_push.send_accessibility_disabled("vivo", "vivo-regid") + + assert data["taskId"] == "vivo-task" + assert calls[0]["url"] == vendor_push.settings.VIVO_PUSH_AUTH_ENDPOINT + assert calls[0]["json"]["appId"] == "106072775" + assert calls[0]["json"]["sign"] + assert calls[1]["url"] == vendor_push.settings.VIVO_PUSH_SEND_ENDPOINT + assert calls[1]["headers"]["authToken"] == "vivo-auth" + body = calls[1]["json"] + assert body["regId"] == "vivo-regid" + assert body["pushMode"] == vendor_push.settings.VIVO_PUSH_MODE + assert body["clientCustomMap"] == {"type": "accessibility_disabled"} + + +def test_oppo_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.OPPO_PUSH_AUTH_ENDPOINT: + return _Resp({"code": 0, "data": {"auth_token": "oppo-auth"}}) + return _Resp({"code": 0, "data": {"message_id": "oppo-msg"}}) + + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_APP_KEY", "oppo-key") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_MASTER_SECRET", "oppo-master") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + data = vendor_push.send_accessibility_disabled("oppo", "oppo-regid") + + assert data["data"]["message_id"] == "oppo-msg" + assert calls[0]["data"]["app_key"] == "oppo-key" + assert calls[0]["data"]["sign"] + message = json.loads(calls[1]["data"]["message"]) + assert calls[1]["data"]["auth_token"] == "oppo-auth" + assert message["target_type"] == 2 + assert message["target_value"] == "oppo-regid" + assert json.loads(message["notification"]["action_parameters"]) == { + "type": "accessibility_disabled" + } + + +def test_honor_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.HONOR_PUSH_TOKEN_ENDPOINT: + return _Resp({"access_token": "honor-access", "expires_in": 3600}) + return _Resp({"code": 200, "message": "successful!", "data": {"sendResult": True}}) + + monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_APP_ID", "104559789") + monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_CLIENT_ID", "honor-client") + monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_CLIENT_SECRET", "honor-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + data = vendor_push.send_accessibility_disabled("honor", "honor-token") + + assert data["code"] == 200 + assert calls[0]["data"]["client_id"] == "honor-client" + assert calls[1]["headers"]["Authorization"] == "Bearer honor-access" + assert calls[1]["headers"]["timestamp"] + assert calls[1]["url"].endswith("/api/v1/104559789/sendMessage") + body = calls[1]["json"] + assert body["token"] == ["honor-token"] + assert body["android"]["targetUserType"] == 1 + assert body["android"]["notification"]["clickAction"] == {"type": 3} + assert json.loads(body["data"]) == {"type": "accessibility_disabled"} + + +def _seed_overdue_device( + *, + phone: str, + device_id: str, + push_vendor: str | None, + push_token: str | None, +) -> int: + with SessionLocal() as db: + user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="sms") + device = DeviceLiveness( + user_id=user.id, + device_id=device_id, + push_vendor=push_vendor, + push_token=push_token, + platform="android", + ever_protected=True, + last_heartbeat_at=datetime.now(timezone.utc) - timedelta(minutes=30), # noqa: UP017 + last_report_protection_on=True, + liveness_state="alive", + kill_alert_pending=False, + ) + db.add(device) + db.commit() + db.refresh(device) + return device.id + + +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 test_heartbeat_monitor_pushes_overdue_device(monkeypatch) -> None: + device_pk = _seed_overdue_device( + phone="13900009001", + device_id="dev-push-honor", + push_vendor="honor", + push_token="honor-token-1", + ) + calls: list[tuple[str, str]] = [] + + def _fake_send(push_vendor: str, push_token: str) -> dict: + calls.append((push_vendor, push_token)) + return {"msg_id": "m1"} + + monkeypatch.setattr( + heartbeat_monitor_worker.vendor_push, + "send_accessibility_disabled", + _fake_send, + ) + + result = heartbeat_monitor_worker._scan_once(timeout_minutes=10) + + assert result["pushed"] >= 1 + assert ("honor", "honor-token-1") in calls + with SessionLocal() as db: + device = db.get(DeviceLiveness, device_pk) + assert device is not None + assert device.liveness_state == "notified" + assert device.kill_alert_pending is True + + +def test_heartbeat_monitor_skips_push_without_vendor_token(monkeypatch) -> None: + device_pk = _seed_overdue_device( + phone="13900009002", + device_id="dev-push-no-token", + push_vendor=None, + push_token=None, + ) + + def _fake_send(push_vendor: str, push_token: str) -> dict: + raise AssertionError(f"should not push without token: {push_vendor}/{push_token}") + + monkeypatch.setattr( + heartbeat_monitor_worker.vendor_push, + "send_accessibility_disabled", + _fake_send, + ) + + result = heartbeat_monitor_worker._scan_once(timeout_minutes=10) + + assert result["checked"] >= 1 + with SessionLocal() as db: + device = db.get(DeviceLiveness, device_pk) + assert device is not None + assert device.liveness_state == "notified" + assert device.kill_alert_pending is True + + +def test_push_test_endpoint_schedules_vendor_push(client: TestClient, monkeypatch) -> None: + token = _login(client, "13900009003") + calls: list[tuple[str, str, str, str]] = [] + + def _fake_send(push_vendor: str, push_token: str, *, title: str, alert: str) -> dict: + calls.append((push_vendor, push_token, title, alert)) + return {"msg_id": "m-test"} + + monkeypatch.setattr(device_api.vendor_push, "send_accessibility_disabled", _fake_send) + + r = client.post( + "/api/v1/device/push-test", + json={ + "device_id": "dev-push-test", + "push_vendor": "honor", + "push_token": "honor-test-token", + "delay_seconds": 0, + }, + headers=_auth(token), + ) + + assert r.status_code == 200, r.text + assert r.json() == { + "ok": True, + "delay_seconds": 0, + "has_push_token": True, + } + assert calls == [ + ( + "honor", + "honor-test-token", + "测试推送", + "这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。", + ) + ] + + +def test_push_test_endpoint_requires_vendor_token(client: TestClient) -> None: + token = _login(client, "13900009004") + + r = client.post( + "/api/v1/device/push-test", + json={"device_id": "dev-push-test-no-token", "delay_seconds": 0}, + headers=_auth(token), + ) + + assert r.status_code == 409 + assert r.json()["detail"] == "push vendor token not ready" -- 2.52.0 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 2/3] =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=203=20=E6=8E=A5=E5=8F=A3(mock=20=E9=98=B6?= =?UTF-8?q?=E6=AE=B5)=20+=20=E4=BA=94=E5=8E=82=E5=95=86=E6=8E=A8=E9=80=81(?= =?UTF-8?q?=E8=A1=A5=E5=8D=8E=E4=B8=BA/=E9=80=9A=E7=94=A8=E5=8F=91?= =?UTF-8?q?=E9=80=81/=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 -- 2.52.0 From d6016c12f913863e98cd1e1cb4e2b3db42ffb581 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 21:22:25 +0800 Subject: [PATCH 3/3] =?UTF-8?q?OPPO=20=E6=8E=A8=E9=80=81=E8=A1=A5=E6=96=B0?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=88=86=E7=B1=BB(channel=5Fid/category/noti?= =?UTF-8?q?fy=5Flevel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2024-11-20 后创建的 OPPO 应用发送通知必须携带 category(新规), 配置项 OPPO_PUSH_CHANNEL_ID / OPPO_PUSH_CATEGORY / OPPO_PUSH_NOTIFY_LEVEL(0=不传), 均留空时 payload 与原先完全一致。凭据已到位:荣耀/小米(含 channel 154219)/OPPO/vivo 四家配齐(.env,不入库),仅剩华为待补。 Co-Authored-By: Claude Fable 5 --- .env.example | 5 +++ app/core/config.py | 6 ++++ app/integrations/vendor_push.py | 26 ++++++++++------ tests/test_push_center.py | 54 +++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 542cce5..c9d1339 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,11 @@ OPPO_PUSH_APP_KEY= OPPO_PUSH_MASTER_SECRET= OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast +# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」; +# notify_level 0=不传走默认,内容营销类仅支持 1/2) +OPPO_PUSH_CHANNEL_ID= +OPPO_PUSH_CATEGORY= +OPPO_PUSH_NOTIFY_LEVEL=0 # ===== 无障碍保护存活监控(推送 + pull 后置兜底)===== HEARTBEAT_MONITOR_ENABLED=true diff --git a/app/core/config.py b/app/core/config.py index d295acc..cdcc5e6 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -111,6 +111,12 @@ class Settings(BaseSettings): OPPO_PUSH_SEND_ENDPOINT: str = ( "https://api.push.oppomobile.com/server/v1/message/notification/unicast" ) + # OPPO 新消息分类(2024-11-20 后创建的应用必须携带,否则可能被拒/限): + # channel_id=通知栏通道(OPPO 后台「通道ID」),category=消息分类 code(如 MARKETING 内容营销)。 + # notify_level=提醒方式(0=不传走 OPPO 默认;内容营销类仅支持 1 通知栏/2 通知栏+锁屏)。 + OPPO_PUSH_CHANNEL_ID: str = "" + OPPO_PUSH_CATEGORY: str = "" + OPPO_PUSH_NOTIFY_LEVEL: int = 0 # 无障碍保护存活监控后台任务(推送 + pull 后置兜底) HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关 diff --git a/app/integrations/vendor_push.py b/app/integrations/vendor_push.py index 20c7973..fd4c8a0 100644 --- a/app/integrations/vendor_push.py +++ b/app/integrations/vendor_push.py @@ -484,18 +484,26 @@ def _oppo_auth_token() -> str: 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)) + notification: dict[str, Any] = { + "app_message_id": f"{extras.get('type', 'notify')}_{uuid.uuid4().hex}", + "title": title, + "content": body, + "click_action_type": 0, + "off_line": True, + "off_line_ttl": ttl_hours, + "action_parameters": json.dumps(extras, ensure_ascii=False), + } + # 新消息分类(2024-11-20 后创建的 OPPO 应用必须带 category,否则可能被拒收/降级) + if settings.OPPO_PUSH_CHANNEL_ID.strip(): + notification["channel_id"] = settings.OPPO_PUSH_CHANNEL_ID.strip() + if settings.OPPO_PUSH_CATEGORY.strip(): + notification["category"] = settings.OPPO_PUSH_CATEGORY.strip() + if settings.OPPO_PUSH_NOTIFY_LEVEL: + notification["notify_level"] = settings.OPPO_PUSH_NOTIFY_LEVEL message = { "target_type": 2, "target_value": token, - "notification": { - "app_message_id": f"{extras.get('type', 'notify')}_{uuid.uuid4().hex}", - "title": title, - "content": body, - "click_action_type": 0, - "off_line": True, - "off_line_ttl": ttl_hours, - "action_parameters": json.dumps(extras, ensure_ascii=False), - }, + "notification": notification, } data = _request_form( "POST", diff --git a/tests/test_push_center.py b/tests/test_push_center.py index 4f116be..491c5a5 100644 --- a/tests/test_push_center.py +++ b/tests/test_push_center.py @@ -155,6 +155,60 @@ def test_accessibility_wrapper_keeps_legacy_extras(monkeypatch) -> None: assert json.loads(captured["data"]["payload"]) == {"type": "accessibility_disabled"} +def test_oppo_payload_includes_new_message_category(monkeypatch) -> None: + """OPPO 新消息分类:配置了 channel_id/category 时随通知体下发(2024-11 新规必带)。""" + 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.OPPO_PUSH_AUTH_ENDPOINT: + return _Resp({"code": 0, "data": {"auth_token": "oppo-auth"}}) + return _Resp({"code": 0, "data": {"message_id": "oppo-msg"}}) + + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_APP_KEY", "oppo-key") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_MASTER_SECRET", "oppo-master") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_CHANNEL_ID", "push_oplus_category_content") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_CATEGORY", "MARKETING") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_NOTIFY_LEVEL", 0) + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + vendor_push.send_notification( + "oppo", "oppo-regid", title="标题", body="正文", extras={"type": "push_test"} + ) + + notification = json.loads(calls[1]["data"]["message"])["notification"] + assert notification["channel_id"] == "push_oplus_category_content" + assert notification["category"] == "MARKETING" + assert "notify_level" not in notification # 0=不传,走 OPPO 默认 + + +def test_oppo_payload_omits_category_when_unconfigured(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.OPPO_PUSH_AUTH_ENDPOINT: + return _Resp({"code": 0, "data": {"auth_token": "oppo-auth"}}) + return _Resp({"code": 0, "data": {"message_id": "oppo-msg"}}) + + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_APP_KEY", "oppo-key") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_MASTER_SECRET", "oppo-master") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_CHANNEL_ID", "") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_CATEGORY", "") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_NOTIFY_LEVEL", 0) + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + vendor_push.send_notification( + "oppo", "oppo-regid", title="标题", body="正文", extras={"type": "push_test"} + ) + + notification = json.loads(calls[1]["data"]["message"])["notification"] + assert "channel_id" not in notification + assert "category" not in notification + + # --------------------------------------------------------------------------- # /api/v1/push 三件套 # --------------------------------------------------------------------------- -- 2.52.0