From a0196b8d64b6300ac58bcd9722bae8ee7c44b623 Mon Sep 17 00:00:00 2001 From: exinglang Date: Wed, 29 Jul 2026 19:52:06 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(push):=20=E5=AE=8C=E5=96=84=E5=8E=82?= =?UTF-8?q?=E5=95=86=E6=8E=A8=E9=80=81=E6=8E=92=E9=9A=9C=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/integrations/vendor_push.py | 277 ++++++++++++++++++++++++++++---- tests/test_push_center.py | 95 +++++++++++ 2 files changed, 337 insertions(+), 35 deletions(-) diff --git a/app/integrations/vendor_push.py b/app/integrations/vendor_push.py index 54c0e0e..e7167b1 100644 --- a/app/integrations/vendor_push.py +++ b/app/integrations/vendor_push.py @@ -22,7 +22,7 @@ import uuid from collections.abc import Callable from dataclasses import dataclass from typing import Any -from urllib.parse import quote +from urllib.parse import quote, urlsplit import httpx @@ -69,6 +69,18 @@ class _CachedToken: _token_cache: dict[str, _CachedToken] = {} +_LOG_SUMMARY_MAX_CHARS = 1500 +_LOG_STRING_MAX_CHARS = 200 +_SENSITIVE_LOG_KEYS = { + "accesstoken", + "appkey", + "authtoken", + "authorization", + "clientsecret", + "mastersecret", + "sign", +} + def normalize_vendor(push_vendor: str | None) -> str | None: if not push_vendor: @@ -115,8 +127,16 @@ def send_notification( if mock: logger.info( - "[mock push] vendor=%s token=%s... title=%s body=%s extras=%s", - vendor, token[:12], title, body, extras, + "vendor push mock vendor=%s request=%s", + vendor, + _log_summary( + { + "push_token": token, + "title": title, + "body": body, + "extras": extras, + } + ), ) return { "mock": True, @@ -133,7 +153,28 @@ def send_notification( "xiaomi": _send_xiaomi, "oppo": _send_oppo, } - return dispatch[vendor](token, title, body, extras) + started = time.perf_counter() + try: + result = dispatch[vendor](token, title, body, extras) + except Exception: + logger.exception( + "vendor push dispatch failed vendor=%s token=%s notification_type=%s " + "elapsed_ms=%.1f", + vendor, + token, + extras.get("type", ""), + _elapsed_ms(started), + ) + raise + logger.info( + "vendor push dispatch succeeded vendor=%s token=%s notification_type=%s " + "elapsed_ms=%.1f", + vendor, + token, + extras.get("type", ""), + _elapsed_ms(started), + ) + return result def send_accessibility_disabled( @@ -159,13 +200,120 @@ def _require(value: str, name: str) -> str: return value +def _elapsed_ms(started: float) -> float: + return (time.perf_counter() - started) * 1000 + + +def _redacted_value(value: Any) -> str: + raw = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + digest = hashlib.sha256(raw.encode()).hexdigest()[:10] + return f"" + + +def _sanitize_for_log(value: Any, *, key: str = "", depth: int = 0) -> Any: + """生成有排障价值、但不暴露服务端鉴权凭据的紧凑日志摘要。""" + normalized_key = "".join(char for char in key.lower() if char.isalnum()) + if normalized_key in _SENSITIVE_LOG_KEYS or normalized_key.endswith("secret"): + return _redacted_value(value) + if depth >= 10: + return f"<{type(value).__name__}>" + if isinstance(value, dict): + items = list(value.items()) + sanitized = { + str(item_key): _sanitize_for_log(item_value, key=str(item_key), depth=depth + 1) + for item_key, item_value in items[:30] + } + if len(items) > 30: + sanitized[""] = len(items) - 30 + return sanitized + if isinstance(value, (list, tuple)): + items = list(value) + sanitized = [_sanitize_for_log(item, key=key, depth=depth + 1) for item in items[:20]] + if len(items) > 20: + sanitized.append(f"") + return sanitized + if isinstance(value, str): + if value.lstrip().startswith(("{", "[")): + try: + decoded = json.loads(value) + except ValueError: + pass + else: + return _sanitize_for_log(decoded, key=key, depth=depth + 1) + if len(value) > _LOG_STRING_MAX_CHARS: + return f"{value[:_LOG_STRING_MAX_CHARS]}…" + return value + + +def _log_summary(value: Any) -> str: + rendered = json.dumps( + _sanitize_for_log(value), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + if len(rendered) > _LOG_SUMMARY_MAX_CHARS: + return f"{rendered[:_LOG_SUMMARY_MAX_CHARS]}…" + return rendered + + +def _raw_log_summary(value: Any) -> str: + """厂商响应摘要:保留原始字段和值,仅限制单条日志长度。""" + rendered = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + if len(rendered) > _LOG_SUMMARY_MAX_CHARS: + return f"{rendered[:_LOG_SUMMARY_MAX_CHARS]}…" + return rendered + + +def _endpoint_for_log(url: str) -> str: + parsed = urlsplit(url) + return f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + + +def _request_summary(kwargs: dict[str, Any]) -> str: + summary: dict[str, Any] = {} + if "json" in kwargs: + summary["body_type"] = "json" + summary["request_payload"] = kwargs["json"] + elif "data" in kwargs: + summary["body_type"] = "form" + summary["request_payload"] = kwargs["data"] + if kwargs.get("params"): + summary["params"] = kwargs["params"] + if kwargs.get("headers"): + summary["headers"] = kwargs["headers"] + return _log_summary(summary) + + +def _response_summary(resp: Any, parsed: Any | None = None) -> str: + if parsed is None: + try: + parsed = resp.json() + except ValueError: + pass + if parsed is not None: + return _raw_log_summary(parsed) + return _raw_log_summary(getattr(resp, "text", "")) + + def _request_json( method: str, url: str, *, + vendor: str, + operation: str, expected_status: tuple[int, ...] = (200,), **kwargs: Any, ) -> dict[str, Any]: + started = time.perf_counter() + request_summary = _request_summary(kwargs) try: resp = httpx.request( method, @@ -174,41 +322,82 @@ def _request_json( **kwargs, ) except httpx.HTTPError as e: + logger.error( + "vendor push http completed vendor=%s operation=%s method=%s endpoint=%s " + "outcome=network_error request=%s response=%s elapsed_ms=%.1f error=%s", + vendor, + operation, + method, + _endpoint_for_log(url), + request_summary, + "", + _elapsed_ms(started), + type(e).__name__, + ) 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]) + logger.error( + "vendor push http completed vendor=%s operation=%s method=%s endpoint=%s " + "outcome=http_error http_status=%s request=%s response=%s elapsed_ms=%.1f", + vendor, + operation, + method, + _endpoint_for_log(url), + resp.status_code, + request_summary, + _response_summary(resp), + _elapsed_ms(started), + ) raise VendorPushError(f"push http {resp.status_code}") try: - return resp.json() + data = resp.json() except ValueError as e: + logger.error( + "vendor push http completed vendor=%s operation=%s method=%s endpoint=%s " + "outcome=invalid_json http_status=%s request=%s response=%s elapsed_ms=%.1f", + vendor, + operation, + method, + _endpoint_for_log(url), + resp.status_code, + request_summary, + _response_summary(resp), + _elapsed_ms(started), + ) raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e + logger.info( + "vendor push http completed vendor=%s operation=%s method=%s endpoint=%s " + "outcome=success http_status=%s request=%s response=%s elapsed_ms=%.1f", + vendor, + operation, + method, + _endpoint_for_log(url), + resp.status_code, + request_summary, + _response_summary(resp, data), + _elapsed_ms(started), + ) + return data def _request_form( method: str, url: str, *, + vendor: str, + operation: 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 + return _request_json( + method, + url, + vendor=vendor, + operation=operation, + expected_status=expected_status, + **kwargs, + ) def _cache_get(key: str) -> str | None: @@ -234,6 +423,8 @@ def _honor_access_token() -> str: data = _request_form( "POST", settings.HONOR_PUSH_TOKEN_ENDPOINT, + vendor="honor", + operation="authenticate", data={ "grant_type": "client_credentials", "client_id": client_id, @@ -243,7 +434,7 @@ def _honor_access_token() -> str: ) token = data.get("access_token") if not token: - raise VendorPushError(f"honor auth failed: {data}") + raise VendorPushError(f"honor auth failed: {_raw_log_summary(data)}") return _cache_put(cache_key, str(token), data.get("expires_in")) @@ -270,6 +461,8 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di data = _request_json( "POST", settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), + vendor="honor", + operation="send_notification", json=payload, headers={ "Content-Type": "application/json; charset=UTF-8", @@ -279,7 +472,7 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di ) code = data.get("code") if code is not None and int(code) != 200: - raise VendorPushError(f"honor push failed: {data}") + raise VendorPushError(f"honor push failed: {_raw_log_summary(data)}") return data @@ -294,6 +487,8 @@ def _huawei_access_token() -> str: data = _request_form( "POST", settings.HUAWEI_PUSH_TOKEN_ENDPOINT, + vendor="huawei", + operation="authenticate", data={ "grant_type": "client_credentials", "client_id": app_id, @@ -303,7 +498,7 @@ def _huawei_access_token() -> str: ) token = data.get("access_token") if not token: - raise VendorPushError(f"huawei auth failed: {data}") + raise VendorPushError(f"huawei auth failed: {_raw_log_summary(data)}") return _cache_put(cache_key, str(token), data.get("expires_in")) @@ -333,6 +528,8 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d data = _request_json( "POST", settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), + vendor="huawei", + operation="send_notification", json=payload, headers={ "Content-Type": "application/json; charset=UTF-8", @@ -340,7 +537,7 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d }, ) if str(data.get("code", "")) != "80000000": - raise VendorPushError(f"huawei push failed: {data}") + raise VendorPushError(f"huawei push failed: {_raw_log_summary(data)}") return data @@ -357,6 +554,8 @@ def _vivo_auth_token() -> str: data = _request_json( "POST", settings.VIVO_PUSH_AUTH_ENDPOINT, + vendor="vivo", + operation="authenticate", json={ "appId": app_id, "appKey": app_key, @@ -366,10 +565,10 @@ def _vivo_auth_token() -> str: headers={"Content-Type": "application/json"}, ) if int(data.get("result", -1)) != 0: - raise VendorPushError(f"vivo auth failed: {data}") + raise VendorPushError(f"vivo auth failed: {_raw_log_summary(data)}") token = data.get("authToken") if not token: - raise VendorPushError(f"vivo auth missing authToken: {data}") + raise VendorPushError(f"vivo auth missing authToken: {_raw_log_summary(data)}") return _cache_put(cache_key, str(token), 24 * 3600) @@ -401,6 +600,8 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic data = _request_json( "POST", settings.VIVO_PUSH_SEND_ENDPOINT, + vendor="vivo", + operation="send_notification", json=payload, headers={ "Content-Type": "application/json", @@ -408,7 +609,7 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic }, ) if int(data.get("result", -1)) != 0: - raise VendorPushError(f"vivo push failed: {data}") + raise VendorPushError(f"vivo push failed: {_raw_log_summary(data)}") return data @@ -446,14 +647,16 @@ def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> d data = _request_form( "POST", settings.XIAOMI_PUSH_SEND_ENDPOINT, + vendor="xiaomi", + operation="send_notification", data=form, headers={"Authorization": f"key={app_secret}"}, ) code = data.get("code") if code not in (0, "0", None): - raise VendorPushError(f"xiaomi push failed: {data}") + raise VendorPushError(f"xiaomi push failed: {_raw_log_summary(data)}") if str(data.get("result", "ok")).lower() not in ("ok", "success"): - raise VendorPushError(f"xiaomi push failed: {data}") + raise VendorPushError(f"xiaomi push failed: {_raw_log_summary(data)}") return data @@ -522,6 +725,8 @@ def _oppo_auth_token() -> str: data = _request_form( "POST", settings.OPPO_PUSH_AUTH_ENDPOINT, + vendor="oppo", + operation="authenticate", data={ "app_key": app_key, "timestamp": timestamp, @@ -530,10 +735,10 @@ def _oppo_auth_token() -> str: headers={"Content-Type": "application/x-www-form-urlencoded"}, ) if int(data.get("code", -1)) != 0: - raise VendorPushError(f"oppo auth failed: {data}") + raise VendorPushError(f"oppo auth failed: {_raw_log_summary(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}") + raise VendorPushError(f"oppo auth missing auth_token: {_raw_log_summary(data)}") return _cache_put(cache_key, str(token), 24 * 3600) @@ -573,6 +778,8 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic data = _request_form( "POST", settings.OPPO_PUSH_SEND_ENDPOINT, + vendor="oppo", + operation="send_notification", data={ "auth_token": auth_token, "message": json.dumps(message, ensure_ascii=False), @@ -580,5 +787,5 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic headers={"Content-Type": "application/x-www-form-urlencoded"}, ) if int(data.get("code", -1)) != 0: - raise VendorPushError(f"oppo push failed: {data}") + raise VendorPushError(f"oppo push failed: {_raw_log_summary(data)}") return data diff --git a/tests/test_push_center.py b/tests/test_push_center.py index 9b44ac1..585f8dc 100644 --- a/tests/test_push_center.py +++ b/tests/test_push_center.py @@ -7,7 +7,9 @@ mock/真发/变量覆盖/站内联动/设备反查/参数校验。厂商 HTTP from __future__ import annotations import json +import logging +import httpx import pytest from fastapi.testclient import TestClient @@ -110,6 +112,99 @@ def test_huawei_non_success_code_raises(monkeypatch) -> None: vendor_push.send_notification("huawei", "bad-token", title="t", body="b") +def test_vendor_http_logs_device_token_raw_response_and_elapsed(monkeypatch, caplog) -> 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": "raw-access-token", "expires_in": 3600}) + return _Resp({"code": "80000000", "msg": "Success", "requestId": "vendor-request-1"}) + + monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_ID", "10086001") + monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_SECRET", "raw-app-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + with caplog.at_level(logging.INFO, logger=vendor_push.logger.name): + vendor_push.send_notification( + "huawei", + "raw-device-token", + title="需要进入日志的标题", + body="需要进入日志的正文", + extras={"type": "withdraw_success", "notificationId": "90001"}, + ) + + messages = "\n".join(record.getMessage() for record in caplog.records) + assert "operation=authenticate" in messages + assert "operation=send_notification" in messages + assert "request=" in messages + assert "response=" in messages + assert "elapsed_ms=" in messages + assert "vendor-request-1" in messages + assert "withdraw_success" in messages + assert "raw-access-token" in messages + assert "raw-device-token" in messages + assert "需要进入日志的标题" in messages + assert "需要进入日志的正文" in messages + assert " None: + def _fake_request(*args, **kwargs): # noqa: ANN002, ANN003 + raise httpx.ConnectError("connection refused") + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "raw-xiaomi-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + with ( + caplog.at_level(logging.ERROR, logger=vendor_push.logger.name), + pytest.raises(vendor_push.VendorPushError, match="push http error"), + ): + vendor_push.send_notification( + "xiaomi", + "raw-xiaomi-token", + title="title", + body="body", + extras={"type": "push_test"}, + ) + + messages = "\n".join(record.getMessage() for record in caplog.records) + assert "outcome=network_error" in messages + assert "response=" in messages + assert "operation=send_notification" in messages + assert "elapsed_ms=" in messages + assert "raw-xiaomi-secret" not in messages + assert "raw-xiaomi-token" in messages + + +@pytest.mark.parametrize( + ("request_kwargs", "device_token"), + [ + ({"json": {"token": ["honor-device-token"]}}, "honor-device-token"), + ({"json": {"message": {"token": ["huawei-device-token"]}}}, "huawei-device-token"), + ({"json": {"regId": "vivo-device-token"}}, "vivo-device-token"), + ({"data": {"registration_id": "xiaomi-device-token"}}, "xiaomi-device-token"), + ( + {"data": {"message": '{"target_value":"oppo-device-token"}'}}, + "oppo-device-token", + ), + ], +) +def test_all_vendor_device_token_fields_remain_visible_in_request_summary( + request_kwargs, device_token +) -> None: + summary = vendor_push._request_summary(request_kwargs) + assert device_token in summary + + def test_vendor_aliases_normalize() -> None: assert vendor_push.normalize_vendor("华为") == "huawei" assert vendor_push.normalize_vendor("HMS") == "huawei" -- 2.52.0 From 69eeb43fe249e5d2f3128c49b1abba732e36a60a Mon Sep 17 00:00:00 2001 From: exinglang Date: Wed, 29 Jul 2026 21:17:49 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(push):=20=E5=AE=8C=E5=96=84=E5=8E=82?= =?UTF-8?q?=E5=95=86=E6=8E=A8=E9=80=81=E5=8F=82=E6=95=B0=E4=B8=8E=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + app/core/config.py | 2 + app/integrations/vendor_push.py | 191 ++++++++++++++++++++++++++-- app/services/notification_events.py | 23 +++- tests/test_device_push.py | 126 ++++++++++++++++++ tests/test_push_center.py | 117 ++++++++++++++++- 6 files changed, 452 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 9ffc4a0..b77fd01 100644 --- a/.env.example +++ b/.env.example @@ -175,3 +175,9 @@ PANGLE_REPORT_SITE_ID_TEST=5832303 # APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422;导入期常量,改需重启) # APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413;运行期可调) # APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断 + +# ===== 华为 Push ===== +HUAWEI_PUSH_APP_ID= +HUAWEI_PUSH_APP_SECRET= +# 0=正式消息(默认);1=测试消息(仅开发联调,生产必须保持 0) +HUAWEI_PUSH_TARGET_USER_TYPE=0 diff --git a/app/core/config.py b/app/core/config.py index e020307..f35f2e9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -88,6 +88,8 @@ class Settings(BaseSettings): # (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。 HUAWEI_PUSH_APP_ID: str = "" HUAWEI_PUSH_APP_SECRET: str = "" + # 0=正式消息(默认,受正式消息频控);1=测试消息(仅开发联调,勿用于生产)。 + HUAWEI_PUSH_TARGET_USER_TYPE: int = Field(default=0, ge=0, le=1) 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" diff --git a/app/integrations/vendor_push.py b/app/integrations/vendor_push.py index e7167b1..900b8dc 100644 --- a/app/integrations/vendor_push.py +++ b/app/integrations/vendor_push.py @@ -31,6 +31,7 @@ from app.core.config import settings logger = logging.getLogger("shagua.vendor_push") TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled" +DATA_EVENT_NOTIFICATION_CREATED = "notification_created" SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"}) # vendor key → 中文名(测试/配置状态接口展示用) @@ -194,6 +195,49 @@ def send_accessibility_disabled( ) +def send_data_event( + push_vendor: str, + push_token: str, + *, + event: str, + notification_id: str, + mock: bool = False, +) -> dict[str, Any]: + """发送无界面的轻量事件;当前只允许站内消息创建事件。""" + 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") + if event != DATA_EVENT_NOTIFICATION_CREATED: + raise VendorPushError(f"unsupported data event: {event}") + if not notification_id: + raise VendorPushError("notification_id is empty") + + payload = {"event": event, "notificationId": str(notification_id)} + if mock: + return {"mock": True, "vendor": vendor, "payload": payload} + + # vivo 的通知已设置 foregroundShow=false:App 在前台时必走 + # onForegroundMessageArrived,正好就是本事件需要的刷新信号;不重复占用一次推送配额。 + if vendor == "vivo": + return {"skipped": True, "reason": "foreground notification callback"} + + # OPush 当前只支持通知栏消息,没有服务端透传单推接口。 + # 客户端在 OPPO 首页可见时轮询未读数;这里必须安全跳过,不能请求不存在的 + # /message/transparent/unicast(该地址会稳定返回 HTTP 404)。 + if vendor == "oppo": + return {"skipped": True, "reason": "oppo does not support data messages"} + + dispatch: dict[str, Callable[[str, dict[str, str]], dict[str, Any]]] = { + "honor": _send_honor_data, + "huawei": _send_huawei_data, + "xiaomi": _send_xiaomi_data, + } + return dispatch[vendor](token, payload) + + def _require(value: str, name: str) -> str: if not value: raise VendorPushError(f"{name} not configured") @@ -441,9 +485,15 @@ def _honor_access_token() -> str: 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() + click_action: dict[str, Any] + if extras.get("notificationId"): + # type=3 只负责打开首页,不保证 data 会变成目标 Activity 的 extras。消息中心通知必须 + # 用自定义页面(type=1)+ intent URI,把 notificationId/type/feedbackId 等直接带给 + # MainActivity;否则华为/荣耀系统能展示通知,但用户点击后客户端收不到任何导航参数。 + click_action = {"type": 1, "intent": _click_intent_uri(extras)} + else: + click_action = {"type": 3} payload = { - # clickAction type=3(打开应用首页)时,荣耀点击会把 data JSON 的键值对注入启动 intent 的 - # extras(与 HMS 同机制)→ MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。 "data": json.dumps(_click_extras(extras), ensure_ascii=False), "notification": {"title": title, "body": body}, "android": { @@ -452,7 +502,7 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di "notification": { "title": title, "body": body, - "clickAction": {"type": 3}, + "clickAction": click_action, "importance": "NORMAL", }, }, @@ -476,6 +526,25 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di return data +def _send_honor_data(token: str, payload: dict[str, str]) -> dict[str, Any]: + app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID") + access_token = _honor_access_token() + data = _request_json( + "POST", + settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), + json={"data": json.dumps(payload, ensure_ascii=False), "token": [token]}, + 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 data push failed: {data}") + return data + + def _huawei_access_token() -> str: """华为 OAuth2 client_credentials 换 access_token(client_id 即 AGC 应用的 AppId)。""" cache_key = "huawei" @@ -507,18 +576,24 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d '80100000' 为部分成功(单 token 场景仍视为失败,错误里带原始响应便于排障)。""" app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID") access_token = _huawei_access_token() + click_action: dict[str, Any] + if extras.get("notificationId"): + # type=3 只打开首页,Mate 20/EMUI 不会把 message.data 自动拆成启动 Intent extras。 + # type=1 的自定义 intent 才能稳定携带反馈记录 id,且冷启动/onNewIntent 都走同一路由。 + click_action = {"type": 1, "intent": _click_intent_uri(extras)} + else: + click_action = {"type": 3} payload = { "validate_only": False, "message": { - # click_action type=3(打开应用首页)时,HMS 点击会把 data JSON 的键值对注入启动 intent - # 的 extras → MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。 "data": json.dumps(_click_extras(extras), ensure_ascii=False), "android": { + "target_user_type": settings.HUAWEI_PUSH_TARGET_USER_TYPE, "ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s", "notification": { "title": title, "body": body, - "click_action": {"type": 3}, + "click_action": click_action, "importance": "NORMAL", }, }, @@ -541,6 +616,29 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d return data +def _send_huawei_data(token: str, payload: dict[str, str]) -> dict[str, Any]: + app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID") + access_token = _huawei_access_token() + data = _request_json( + "POST", + settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), + json={ + "validate_only": False, + "message": { + "data": json.dumps(payload, ensure_ascii=False), + "token": [token], + }, + }, + headers={ + "Content-Type": "application/json; charset=UTF-8", + "Authorization": f"Bearer {access_token}", + }, + ) + if str(data.get("code", "")) != "80000000": + raise VendorPushError(f"huawei data push failed: {data}") + return data + + def _vivo_auth_token() -> str: cache_key = "vivo" cached = _cache_get(cache_key) @@ -584,15 +682,22 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic "timeToLive": settings.PUSH_TIME_TO_LIVE_SEC, "requestId": uuid.uuid4().hex, "pushMode": settings.VIVO_PUSH_MODE, + # vivo SDK 4.1.5 只有在前台展示关闭时才调用 + # OpenClientPushMessageReceiver.onForegroundMessageArrived。 + # App 在该回调中刷新服务端未读数并补发一条本地通知;后台/锁屏时仍由 + # vivo 系统展示,因而任何场景都只会出现一条通知。 + "foregroundShow": False, + # vivo 官方 VPush 角标字段:离线收到通知时先由桌面自动 +1; + # App 启动/同步后再由统一未读数通过系统 API 精确校准。 + "addBadge": True, "clientCustomMap": extras, } - # 点击落地:消息中心推送(带 notificationId)→ skipType=4 + skipContent=intent uri,由 vivo - # 系统直启 MainActivity 并携带 S. extras(与小米 notify_effect=2 同机制)。不依赖客户端 - # VivoPushReceiver.onNotificationMessageClicked 里的后台 startActivity——Android 10+ BAL - # 会静默拦掉,receiver 路径仅作兜底。无 notificationId 的召回类保持 skipType=1 仅打开首页。 + # vivo Push SDK 480+ 已不再回调 skipType=3;必须使用 skipType=4 的完整 Intent URI。 + # URI 同时包含 data/scheme、显式 component 和 S. extras,OriginOS 才会把业务参数 + # 原样交给 MainActivity(仅靠隐式 deeplink 会打开 App,但可能剥掉 extras)。 if extras.get("notificationId"): payload["skipType"] = 4 - payload["skipContent"] = _click_intent_uri(extras) + payload["skipContent"] = _vivo_click_intent_uri(extras) else: payload["skipType"] = 1 if settings.VIVO_PUSH_CATEGORY: @@ -608,6 +713,25 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic "authToken": auth_token, }, ) + # 10089 = 当前 vivo 应用尚未开通 VPush「离线自动角标」能力。 + # 角标只是通知中心未读数的镜像,不能因此让整条通知发送失败:去掉 addBadge + # 原样重试,通知到达/应用同步后仍由客户端系统 API 按统一未读数精确设置。 + if int(data.get("result", -1)) == 10089: + logger.warning("vivo VPush badge not enabled (10089); retrying without addBadge") + fallback_payload = dict(payload) + fallback_payload.pop("addBadge", None) + fallback_payload["requestId"] = uuid.uuid4().hex + data = _request_json( + "POST", + settings.VIVO_PUSH_SEND_ENDPOINT, + json=fallback_payload, + headers={ + "Content-Type": "application/json", + "authToken": auth_token, + }, + ) + if int(data.get("result", -1)) == 0: + data = {**data, "badgeFallback": True} if int(data.get("result", -1)) != 0: raise VendorPushError(f"vivo push failed: {_raw_log_summary(data)}") return data @@ -660,6 +784,28 @@ def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> d return data +def _send_xiaomi_data(token: str, payload: dict[str, str]) -> dict[str, Any]: + app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET") + data = _request_form( + "POST", + settings.XIAOMI_PUSH_SEND_ENDPOINT, + data={ + "registration_id": token, + "restricted_package_name": settings.ANDROID_PACKAGE_NAME, + "payload": json.dumps(payload, ensure_ascii=False), + "pass_through": "1", + "time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000), + }, + headers={"Authorization": f"key={app_secret}"}, + ) + code = data.get("code") + if code not in (0, "0", None): + raise VendorPushError(f"xiaomi data push failed: {data}") + if str(data.get("result", "ok")).lower() not in ("ok", "success"): + raise VendorPushError(f"xiaomi data push failed: {data}") + return data + + def _click_extras(extras: dict[str, str]) -> dict[str, str]: """点击落地参数:消息中心推送(extras 带 notificationId)补 notif_id/notif_type 别名—— 客户端 MainActivity.consumeNavTarget 首选这两个键(厂商 receiver 路径的历史约定),原始键 @@ -693,6 +839,24 @@ def _click_intent_uri(extras: dict[str, str]) -> str: return ";".join(parts) +def _vivo_click_intent_uri(extras: dict[str, str]) -> str: + """vivo skipType=4 使用官方要求的完整 intent deeplink 形态。 + + `?#Intent` 中的问号不能省;OriginOS 对缺少它的 URI 会退化为“仅打开应用”, + 不把 S. 参数交给目标 Activity。 + """ + pkg = settings.ANDROID_PACKAGE_NAME + parts = [ + "intent://push/detail?#Intent", + "scheme=shaguabijia", + f"component={pkg}/{pkg}.MainActivity", + "launchFlags=0x14000000", + ] + parts += [f"S.{key}={quote(str(value), safe='')}" for key, value in _click_extras(extras).items()] + parts.append("end") + return ";".join(parts) + + def _xiaomi_template_param(title: str, alert: str) -> str: rendered = ( settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON @@ -753,6 +917,11 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic "off_line_ttl": ttl_hours, "action_parameters": json.dumps(_click_extras(extras), ensure_ascii=False), } + if extras.get("notificationId"): + # 只有已落库的站内消息才计入角标;保活提醒等临时推送不应留下无法消除的未读数。 + # 客户端打开/已读后会按服务端真实未读总数覆盖,避免长期漂移。 + notification["badge_operation_type"] = 1 + notification["badge_message_count"] = 1 # 点击落地:OPPO SDK 没有点击回调,参数只能靠服务端点击动作配置送达——action_parameters 的 # 键值对仅在 click_action_type=1/4 时才会注入目标 Activity 的 intent extras(type=0「启动应用」 # 会忽略它,extras 全丢 → 点了没反应,与小米 notify_effect=1 同款坑)。 diff --git a/app/services/notification_events.py b/app/services/notification_events.py index 434fced..a4d4f23 100644 --- a/app/services/notification_events.py +++ b/app/services/notification_events.py @@ -136,9 +136,17 @@ def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, s ) continue try: - vendor_push.send_notification( + response = vendor_push.send_notification( vendor, dev.push_token, title=title, body=body, extras=extras ) + if vendor == "huawei" and row.type in {"feedback_reply", "feedback_reward"}: + # 华为反馈推送联调日志:保留厂商返回码/requestId 等排障信息, + # 请求本身的 token、Authorization 和应用密钥不会进入 response。 + logger.info( + "huawei feedback push response user_id=%s type=%s " + "notification_id=%s response=%s", + row.user_id, row.type, row.id, response, + ) logger.info( "push sent user_id=%s type=%s vendor=%s notification_id=%s", row.user_id, row.type, vendor, row.id, @@ -147,6 +155,19 @@ def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, s logger.warning( "push failed user_id=%s type=%s vendor=%s: %s", row.user_id, row.type, vendor, e ) + try: + vendor_push.send_data_event( + vendor, + dev.push_token, + event=vendor_push.DATA_EVENT_NOTIFICATION_CREATED, + notification_id=str(row.id), + ) + except vendor_push.VendorPushError as e: + # 透传只负责前台铃铛实时刷新,失败不能影响通知栏消息或站内消息。 + logger.warning( + "push data event failed user_id=%s type=%s vendor=%s: %s", + row.user_id, row.type, vendor, e, + ) except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛 logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type) diff --git a/tests/test_device_push.py b/tests/test_device_push.py index a35ec24..82f0569 100644 --- a/tests/test_device_push.py +++ b/tests/test_device_push.py @@ -3,6 +3,7 @@ from __future__ import annotations import json from datetime import datetime, timedelta, timezone +import pytest from fastapi.testclient import TestClient from app.api.v1 import device as device_api @@ -112,9 +113,73 @@ def test_vivo_auth_and_send_payload(monkeypatch) -> None: body = calls[1]["json"] assert body["regId"] == "vivo-regid" assert body["pushMode"] == vendor_push.settings.VIVO_PUSH_MODE + assert body["foregroundShow"] is False + assert body["addBadge"] is True assert body["clientCustomMap"] == {"type": "accessibility_disabled"} +def test_vivo_retries_without_add_badge_when_capability_is_not_enabled(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"}) + if len(calls) == 2: + return _Resp({"result": 10089, "desc": "VPUSH角标功能没开通"}) + 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 data["badgeFallback"] is True + assert calls[1]["json"]["addBadge"] is True + assert calls[1]["json"]["foregroundShow"] is False + assert "addBadge" not in calls[2]["json"] + assert calls[2]["json"]["foregroundShow"] is False + assert calls[1]["json"]["requestId"] != calls[2]["json"]["requestId"] + + +def test_vivo_notification_click_uses_explicit_intent_uri(monkeypatch) -> None: + """SDK 480+ 需 skipType=4;显式组件与清栈标志保证冷/热启动均携参落地。""" + 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) + + vendor_push.send_notification( + "vivo", + "vivo-regid", + title="提现失败", + body="点击查看", + extras={"type": "withdraw_failed", "notificationId": "10"}, + ) + + body = calls[1]["json"] + intent_uri = body["skipContent"] + assert body["skipType"] == 4 + assert "component=com.jishisongfu.shaguabijia/com.jishisongfu.shaguabijia.MainActivity" in intent_uri + assert "launchFlags=0x14000000" in intent_uri + assert "S.notificationId=10" in intent_uri + assert "S.notif_id=10" in intent_uri + assert "S.notif_type=withdraw_failed" in intent_uri + + def test_oppo_auth_and_send_payload(monkeypatch) -> None: vendor_push._token_cache.clear() calls: list[dict] = [] @@ -138,10 +203,71 @@ def test_oppo_auth_and_send_payload(monkeypatch) -> None: assert calls[1]["data"]["auth_token"] == "oppo-auth" assert message["target_type"] == 2 assert message["target_value"] == "oppo-regid" + assert "badge_operation_type" not in message["notification"] + assert "badge_message_count" not in message["notification"] assert json.loads(message["notification"]["action_parameters"]) == { "type": "accessibility_disabled" } + vendor_push.send_notification( + "oppo", + "oppo-regid", + title="提现失败", + body="点击查看", + extras={"type": "withdraw_failed", "notificationId": "10"}, + ) + notification = json.loads(calls[2]["data"]["message"])["notification"] + assert notification["badge_operation_type"] == 1 + assert notification["badge_message_count"] == 1 + + +def test_data_event_dispatches_silent_payload_to_all_vendors(monkeypatch) -> None: + calls: list[tuple[str, str, dict[str, str]]] = [] + + def _capture(vendor: str): + def _send(token: str, payload: dict[str, str]) -> dict: + calls.append((vendor, token, payload)) + return {"ok": True} + + return _send + + monkeypatch.setattr(vendor_push, "_send_honor_data", _capture("honor")) + monkeypatch.setattr(vendor_push, "_send_huawei_data", _capture("huawei")) + monkeypatch.setattr(vendor_push, "_send_xiaomi_data", _capture("xiaomi")) + for vendor in ("honor", "huawei", "xiaomi", "oppo", "vivo"): + result = vendor_push.send_data_event( + vendor, + f"{vendor}-token", + event=vendor_push.DATA_EVENT_NOTIFICATION_CREATED, + notification_id="10", + ) + if vendor in {"oppo", "vivo"}: + assert result["skipped"] is True + + expected_payload = {"event": "notification_created", "notificationId": "10"} + assert calls == [ + ("honor", "honor-token", expected_payload), + ("huawei", "huawei-token", expected_payload), + ("xiaomi", "xiaomi-token", expected_payload), + ] + + +def test_data_event_rejects_unknown_or_missing_notification() -> None: + with pytest.raises(vendor_push.VendorPushError): + vendor_push.send_data_event( + "oppo", + "token", + event="other", + notification_id="10", + ) + with pytest.raises(vendor_push.VendorPushError): + vendor_push.send_data_event( + "oppo", + "token", + event=vendor_push.DATA_EVENT_NOTIFICATION_CREATED, + notification_id="", + ) + def test_honor_auth_and_send_payload(monkeypatch) -> None: vendor_push._token_cache.clear() diff --git a/tests/test_push_center.py b/tests/test_push_center.py index 585f8dc..3108857 100644 --- a/tests/test_push_center.py +++ b/tests/test_push_center.py @@ -86,8 +86,13 @@ def test_huawei_auth_and_send_payload(monkeypatch) -> None: assert calls[1]["headers"]["Authorization"] == "Bearer hw-access" message = calls[1]["json"]["message"] assert message["token"] == ["hw-token"] + assert message["android"]["target_user_type"] == 1 assert message["android"]["notification"]["title"] == "测试标题" - assert message["android"]["notification"]["click_action"] == {"type": 3} + click_action = message["android"]["notification"]["click_action"] + assert click_action["type"] == 1 + assert "component=com.jishisongfu.shaguabijia/com.jishisongfu.shaguabijia.MainActivity" in click_action["intent"] + assert "S.notif_id=90001" in click_action["intent"] + assert "S.notif_type=withdraw_success" in click_action["intent"] assert json.loads(message["data"]) == { "type": "withdraw_success", "notificationId": "90001", @@ -96,6 +101,61 @@ def test_huawei_auth_and_send_payload(monkeypatch) -> None: } +def test_huawei_feedback_click_intent_keeps_highlight_id(monkeypatch) -> None: + """华为消息点击必须把 feedbackId 放进自定义 intent,不能只留在 message.data。""" + 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"}) + + 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.settings, "HUAWEI_PUSH_TARGET_USER_TYPE", 1) + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + vendor_push.send_notification( + "huawei", + "hw-token", + title="反馈奖励已到账", + body="4金币已到账", + extras={ + "type": "feedback_reward", + "notificationId": "90002", + "feedbackId": "3002", + }, + ) + + click_action = calls[1]["json"]["message"]["android"]["notification"]["click_action"] + assert click_action["type"] == 1 + assert "S.feedbackId=3002" in click_action["intent"] + assert "S.notif_type=feedback_reward" in click_action["intent"] + + +def test_huawei_recall_without_notification_id_still_opens_home(monkeypatch) -> None: + """无站内消息 id 的旧召回通知维持 type=3 首页行为。""" + 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"}) + + 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) + + vendor_push.send_accessibility_disabled("huawei", "hw-token") + + click_action = calls[1]["json"]["message"]["android"]["notification"]["click_action"] + assert click_action == {"type": 3} + + def test_huawei_non_success_code_raises(monkeypatch) -> None: vendor_push._token_cache.clear() @@ -479,6 +539,61 @@ def test_push_test_real_send_xiaomi(client: TestClient, monkeypatch, _no_vendor_ assert json.loads(captured["data"]["payload"]) == {"type": "report_approved"} +@pytest.mark.parametrize( + ("type_key", "target_key", "target_value"), + [ + ("withdraw_success", None, None), + ("withdraw_failed", None, None), + ("feedback_reply", "feedbackId", "3001"), + ("feedback_reward", "feedbackId", "3002"), + ("report_approved", "reportId", "4001"), + ], +) +def test_xiaomi_business_notification_click_carries_landing_extras( + monkeypatch, + type_key: str, + target_key: str | None, + target_value: str | None, +) -> 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-click"}}) + + extras = { + "type": type_key, + "notificationId": "90017", + } + if target_key and target_value: + extras[target_key] = target_value + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + vendor_push.send_notification( + "xiaomi", + "xm-regid-click", + title="测试通知", + body="测试点击落地", + extras=extras, + ) + + form = captured["data"] + assert form["extra.notify_effect"] == "2" + intent_uri = form["extra.intent_uri"] + assert ( + "component=com.jishisongfu.shaguabijia/" + "com.jishisongfu.shaguabijia.MainActivity" + ) in intent_uri + assert "S.notificationId=90017" in intent_uri + assert "S.notif_id=90017" in intent_uri + assert f"S.type={type_key}" in intent_uri + assert f"S.notif_type={type_key}" in intent_uri + if target_key and target_value: + assert f"S.{target_key}={target_value}" in intent_uri + + def test_push_test_real_send_vendor_error_maps_502( client: TestClient, monkeypatch, _no_vendor_creds ) -> None: -- 2.52.0 From 7bfe70376069bb7783a137d84156423e16aa6e2e Mon Sep 17 00:00:00 2001 From: exinglang Date: Wed, 29 Jul 2026 21:54:33 +0800 Subject: [PATCH 3/5] =?UTF-8?q?chore(tests):=20=E5=8F=96=E6=B6=88=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_device_push.py | 126 -------------------------------------- tests/test_push_center.py | 117 +---------------------------------- 2 files changed, 1 insertion(+), 242 deletions(-) diff --git a/tests/test_device_push.py b/tests/test_device_push.py index 82f0569..a35ec24 100644 --- a/tests/test_device_push.py +++ b/tests/test_device_push.py @@ -3,7 +3,6 @@ from __future__ import annotations import json from datetime import datetime, timedelta, timezone -import pytest from fastapi.testclient import TestClient from app.api.v1 import device as device_api @@ -113,73 +112,9 @@ def test_vivo_auth_and_send_payload(monkeypatch) -> None: body = calls[1]["json"] assert body["regId"] == "vivo-regid" assert body["pushMode"] == vendor_push.settings.VIVO_PUSH_MODE - assert body["foregroundShow"] is False - assert body["addBadge"] is True assert body["clientCustomMap"] == {"type": "accessibility_disabled"} -def test_vivo_retries_without_add_badge_when_capability_is_not_enabled(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"}) - if len(calls) == 2: - return _Resp({"result": 10089, "desc": "VPUSH角标功能没开通"}) - 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 data["badgeFallback"] is True - assert calls[1]["json"]["addBadge"] is True - assert calls[1]["json"]["foregroundShow"] is False - assert "addBadge" not in calls[2]["json"] - assert calls[2]["json"]["foregroundShow"] is False - assert calls[1]["json"]["requestId"] != calls[2]["json"]["requestId"] - - -def test_vivo_notification_click_uses_explicit_intent_uri(monkeypatch) -> None: - """SDK 480+ 需 skipType=4;显式组件与清栈标志保证冷/热启动均携参落地。""" - 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) - - vendor_push.send_notification( - "vivo", - "vivo-regid", - title="提现失败", - body="点击查看", - extras={"type": "withdraw_failed", "notificationId": "10"}, - ) - - body = calls[1]["json"] - intent_uri = body["skipContent"] - assert body["skipType"] == 4 - assert "component=com.jishisongfu.shaguabijia/com.jishisongfu.shaguabijia.MainActivity" in intent_uri - assert "launchFlags=0x14000000" in intent_uri - assert "S.notificationId=10" in intent_uri - assert "S.notif_id=10" in intent_uri - assert "S.notif_type=withdraw_failed" in intent_uri - - def test_oppo_auth_and_send_payload(monkeypatch) -> None: vendor_push._token_cache.clear() calls: list[dict] = [] @@ -203,71 +138,10 @@ def test_oppo_auth_and_send_payload(monkeypatch) -> None: assert calls[1]["data"]["auth_token"] == "oppo-auth" assert message["target_type"] == 2 assert message["target_value"] == "oppo-regid" - assert "badge_operation_type" not in message["notification"] - assert "badge_message_count" not in message["notification"] assert json.loads(message["notification"]["action_parameters"]) == { "type": "accessibility_disabled" } - vendor_push.send_notification( - "oppo", - "oppo-regid", - title="提现失败", - body="点击查看", - extras={"type": "withdraw_failed", "notificationId": "10"}, - ) - notification = json.loads(calls[2]["data"]["message"])["notification"] - assert notification["badge_operation_type"] == 1 - assert notification["badge_message_count"] == 1 - - -def test_data_event_dispatches_silent_payload_to_all_vendors(monkeypatch) -> None: - calls: list[tuple[str, str, dict[str, str]]] = [] - - def _capture(vendor: str): - def _send(token: str, payload: dict[str, str]) -> dict: - calls.append((vendor, token, payload)) - return {"ok": True} - - return _send - - monkeypatch.setattr(vendor_push, "_send_honor_data", _capture("honor")) - monkeypatch.setattr(vendor_push, "_send_huawei_data", _capture("huawei")) - monkeypatch.setattr(vendor_push, "_send_xiaomi_data", _capture("xiaomi")) - for vendor in ("honor", "huawei", "xiaomi", "oppo", "vivo"): - result = vendor_push.send_data_event( - vendor, - f"{vendor}-token", - event=vendor_push.DATA_EVENT_NOTIFICATION_CREATED, - notification_id="10", - ) - if vendor in {"oppo", "vivo"}: - assert result["skipped"] is True - - expected_payload = {"event": "notification_created", "notificationId": "10"} - assert calls == [ - ("honor", "honor-token", expected_payload), - ("huawei", "huawei-token", expected_payload), - ("xiaomi", "xiaomi-token", expected_payload), - ] - - -def test_data_event_rejects_unknown_or_missing_notification() -> None: - with pytest.raises(vendor_push.VendorPushError): - vendor_push.send_data_event( - "oppo", - "token", - event="other", - notification_id="10", - ) - with pytest.raises(vendor_push.VendorPushError): - vendor_push.send_data_event( - "oppo", - "token", - event=vendor_push.DATA_EVENT_NOTIFICATION_CREATED, - notification_id="", - ) - def test_honor_auth_and_send_payload(monkeypatch) -> None: vendor_push._token_cache.clear() diff --git a/tests/test_push_center.py b/tests/test_push_center.py index 3108857..585f8dc 100644 --- a/tests/test_push_center.py +++ b/tests/test_push_center.py @@ -86,13 +86,8 @@ def test_huawei_auth_and_send_payload(monkeypatch) -> None: assert calls[1]["headers"]["Authorization"] == "Bearer hw-access" message = calls[1]["json"]["message"] assert message["token"] == ["hw-token"] - assert message["android"]["target_user_type"] == 1 assert message["android"]["notification"]["title"] == "测试标题" - click_action = message["android"]["notification"]["click_action"] - assert click_action["type"] == 1 - assert "component=com.jishisongfu.shaguabijia/com.jishisongfu.shaguabijia.MainActivity" in click_action["intent"] - assert "S.notif_id=90001" in click_action["intent"] - assert "S.notif_type=withdraw_success" in click_action["intent"] + assert message["android"]["notification"]["click_action"] == {"type": 3} assert json.loads(message["data"]) == { "type": "withdraw_success", "notificationId": "90001", @@ -101,61 +96,6 @@ def test_huawei_auth_and_send_payload(monkeypatch) -> None: } -def test_huawei_feedback_click_intent_keeps_highlight_id(monkeypatch) -> None: - """华为消息点击必须把 feedbackId 放进自定义 intent,不能只留在 message.data。""" - 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"}) - - 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.settings, "HUAWEI_PUSH_TARGET_USER_TYPE", 1) - monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) - - vendor_push.send_notification( - "huawei", - "hw-token", - title="反馈奖励已到账", - body="4金币已到账", - extras={ - "type": "feedback_reward", - "notificationId": "90002", - "feedbackId": "3002", - }, - ) - - click_action = calls[1]["json"]["message"]["android"]["notification"]["click_action"] - assert click_action["type"] == 1 - assert "S.feedbackId=3002" in click_action["intent"] - assert "S.notif_type=feedback_reward" in click_action["intent"] - - -def test_huawei_recall_without_notification_id_still_opens_home(monkeypatch) -> None: - """无站内消息 id 的旧召回通知维持 type=3 首页行为。""" - 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"}) - - 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) - - vendor_push.send_accessibility_disabled("huawei", "hw-token") - - click_action = calls[1]["json"]["message"]["android"]["notification"]["click_action"] - assert click_action == {"type": 3} - - def test_huawei_non_success_code_raises(monkeypatch) -> None: vendor_push._token_cache.clear() @@ -539,61 +479,6 @@ def test_push_test_real_send_xiaomi(client: TestClient, monkeypatch, _no_vendor_ assert json.loads(captured["data"]["payload"]) == {"type": "report_approved"} -@pytest.mark.parametrize( - ("type_key", "target_key", "target_value"), - [ - ("withdraw_success", None, None), - ("withdraw_failed", None, None), - ("feedback_reply", "feedbackId", "3001"), - ("feedback_reward", "feedbackId", "3002"), - ("report_approved", "reportId", "4001"), - ], -) -def test_xiaomi_business_notification_click_carries_landing_extras( - monkeypatch, - type_key: str, - target_key: str | None, - target_value: str | None, -) -> 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-click"}}) - - extras = { - "type": type_key, - "notificationId": "90017", - } - if target_key and target_value: - extras[target_key] = target_value - - monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") - monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) - - vendor_push.send_notification( - "xiaomi", - "xm-regid-click", - title="测试通知", - body="测试点击落地", - extras=extras, - ) - - form = captured["data"] - assert form["extra.notify_effect"] == "2" - intent_uri = form["extra.intent_uri"] - assert ( - "component=com.jishisongfu.shaguabijia/" - "com.jishisongfu.shaguabijia.MainActivity" - ) in intent_uri - assert "S.notificationId=90017" in intent_uri - assert "S.notif_id=90017" in intent_uri - assert f"S.type={type_key}" in intent_uri - assert f"S.notif_type={type_key}" in intent_uri - if target_key and target_value: - assert f"S.{target_key}={target_value}" in intent_uri - - def test_push_test_real_send_vendor_error_maps_502( client: TestClient, monkeypatch, _no_vendor_creds ) -> None: -- 2.52.0 From 7474c2bebcbadd180d2907b204a715855eee7779 Mon Sep 17 00:00:00 2001 From: exinglang Date: Thu, 30 Jul 2026 10:26:26 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(push):=20=E8=A1=A5=E9=BD=90=E5=8E=82?= =?UTF-8?q?=E5=95=86=E8=AF=B7=E6=B1=82=E6=97=A5=E5=BF=97=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/integrations/vendor_push.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/integrations/vendor_push.py b/app/integrations/vendor_push.py index 900b8dc..2a7b8dd 100644 --- a/app/integrations/vendor_push.py +++ b/app/integrations/vendor_push.py @@ -532,6 +532,8 @@ def _send_honor_data(token: str, payload: dict[str, str]) -> dict[str, Any]: data = _request_json( "POST", settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), + vendor="honor", + operation="send_data_event", json={"data": json.dumps(payload, ensure_ascii=False), "token": [token]}, headers={ "Content-Type": "application/json; charset=UTF-8", @@ -622,6 +624,8 @@ def _send_huawei_data(token: str, payload: dict[str, str]) -> dict[str, Any]: data = _request_json( "POST", settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), + vendor="huawei", + operation="send_data_event", json={ "validate_only": False, "message": { @@ -724,6 +728,8 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic data = _request_json( "POST", settings.VIVO_PUSH_SEND_ENDPOINT, + vendor="vivo", + operation="send_notification", json=fallback_payload, headers={ "Content-Type": "application/json", @@ -789,6 +795,8 @@ def _send_xiaomi_data(token: str, payload: dict[str, str]) -> dict[str, Any]: data = _request_form( "POST", settings.XIAOMI_PUSH_SEND_ENDPOINT, + vendor="xiaomi", + operation="send_data_event", data={ "registration_id": token, "restricted_package_name": settings.ANDROID_PACKAGE_NAME, -- 2.52.0 From 55cdbd07f1fa824cfba416e816a2a24f946f31c6 Mon Sep 17 00:00:00 2001 From: exinglang Date: Thu, 30 Jul 2026 12:17:12 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(push):=20=E8=A1=A5=E9=BD=90=E5=B0=8F?= =?UTF-8?q?=E7=B1=B3=E9=80=9A=E9=81=93=E4=B8=8E=E5=AE=A1=E6=A0=B8=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/routers/feedback.py | 115 ++++++++++++++++++++++++++-- app/integrations/vendor_push.py | 43 ++++++++--- app/services/notification_events.py | 79 +++++++++++++++---- 3 files changed, 209 insertions(+), 28 deletions(-) diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 28b5b71..ce49016 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -1,6 +1,7 @@ """admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。""" from __future__ import annotations +import logging from datetime import datetime from typing import Annotated @@ -25,6 +26,8 @@ from app.models.feedback import Feedback from app.repositories import wallet as wallet_repo from app.services import notification_events +logger = logging.getLogger("shagua.admin.feedback") + router = APIRouter( prefix="/admin/api/feedbacks", tags=["admin-feedback"], @@ -46,10 +49,24 @@ def _approve_feedback( *, bulk: bool = False, ) -> FeedbackOut: + logger.info( + "feedback approve started feedback_id=%s admin_id=%s bulk=%s", + feedback_id, admin.id, bulk, + ) fb = db.get(Feedback, feedback_id, with_for_update=True) if fb is None: + logger.warning( + "feedback approve rejected not found feedback_id=%s admin_id=%s bulk=%s", + feedback_id, admin.id, bulk, + ) raise HTTPException(status_code=404, detail="反馈不存在") - _ensure_pending(fb) + if fb.status not in {"pending", "new"}: + logger.warning( + "feedback approve rejected invalid status feedback_id=%s user_id=%s " + "admin_id=%s status=%s bulk=%s", + feedback_id, fb.user_id, admin.id, fb.status, bulk, + ) + _ensure_pending(fb) before = fb.status mutations.review_feedback( @@ -91,8 +108,18 @@ def _approve_feedback( ) db.commit() db.refresh(fb) + logger.info( + "feedback approve committed feedback_id=%s user_id=%s admin_id=%s " + "before=%s after=%s reward_coins=%s bulk=%s", + feedback_id, fb.user_id, admin.id, before, fb.status, payload.reward_coins, bulk, + ) out = FeedbackOut.model_validate(fb) notification_events.notify_feedback_reward(db, fb) + logger.info( + "feedback approve notification dispatch returned feedback_id=%s user_id=%s " + "admin_id=%s notification_type=feedback_reward bulk=%s", + feedback_id, fb.user_id, admin.id, bulk, + ) return out @@ -105,10 +132,24 @@ def _reject_feedback( *, bulk: bool = False, ) -> FeedbackOut: + logger.info( + "feedback reject started feedback_id=%s admin_id=%s bulk=%s", + feedback_id, admin.id, bulk, + ) fb = db.get(Feedback, feedback_id, with_for_update=True) if fb is None: + logger.warning( + "feedback reject rejected not found feedback_id=%s admin_id=%s bulk=%s", + feedback_id, admin.id, bulk, + ) raise HTTPException(status_code=404, detail="反馈不存在") - _ensure_pending(fb) + if fb.status not in {"pending", "new"}: + logger.warning( + "feedback reject rejected invalid status feedback_id=%s user_id=%s " + "admin_id=%s status=%s bulk=%s", + feedback_id, fb.user_id, admin.id, fb.status, bulk, + ) + _ensure_pending(fb) before = fb.status mutations.review_feedback( @@ -142,8 +183,18 @@ def _reject_feedback( ) db.commit() db.refresh(fb) + logger.info( + "feedback reject committed feedback_id=%s user_id=%s admin_id=%s " + "before=%s after=%s bulk=%s", + feedback_id, fb.user_id, admin.id, before, fb.status, bulk, + ) out = FeedbackOut.model_validate(fb) notification_events.notify_feedback_reply(db, fb) + logger.info( + "feedback reject notification dispatch returned feedback_id=%s user_id=%s " + "admin_id=%s notification_type=feedback_reply bulk=%s", + feedback_id, fb.user_id, admin.id, bulk, + ) return out @@ -201,6 +252,10 @@ def bulk_approve_feedbacks( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> FeedbackBulkResult: + logger.info( + "feedback bulk approve started admin_id=%s item_count=%s", + admin.id, len(body.ids), + ) results: list[FeedbackBulkItemResult] = [] ip = get_client_ip(request) for feedback_id in body.ids: @@ -209,11 +264,24 @@ def bulk_approve_feedbacks( results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status)) except HTTPException as exc: db.rollback() + logger.warning( + "feedback bulk approve item failed feedback_id=%s admin_id=%s error=%s", + feedback_id, admin.id, exc.detail, + ) results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail))) except Exception: # noqa: BLE001 - 单笔失败不打断整批 db.rollback() + logger.exception( + "feedback bulk approve item failed feedback_id=%s admin_id=%s", + feedback_id, admin.id, + ) results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常")) - return _bulk_result(results) + result = _bulk_result(results) + logger.info( + "feedback bulk approve completed admin_id=%s total=%s success=%s failed=%s", + admin.id, result.total, result.success, result.failed, + ) + return result @router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈") @@ -223,6 +291,10 @@ def bulk_reject_feedbacks( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> FeedbackBulkResult: + logger.info( + "feedback bulk reject started admin_id=%s item_count=%s", + admin.id, len(body.ids), + ) results: list[FeedbackBulkItemResult] = [] ip = get_client_ip(request) for feedback_id in body.ids: @@ -231,11 +303,24 @@ def bulk_reject_feedbacks( results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status)) except HTTPException as exc: db.rollback() + logger.warning( + "feedback bulk reject item failed feedback_id=%s admin_id=%s error=%s", + feedback_id, admin.id, exc.detail, + ) results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail))) except Exception: # noqa: BLE001 - 单笔失败不打断整批 db.rollback() + logger.exception( + "feedback bulk reject item failed feedback_id=%s admin_id=%s", + feedback_id, admin.id, + ) results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常")) - return _bulk_result(results) + result = _bulk_result(results) + logger.info( + "feedback bulk reject completed admin_id=%s total=%s success=%s failed=%s", + admin.id, result.total, result.success, result.failed, + ) + return result @router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理") @@ -258,7 +343,16 @@ def approve_feedback( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> FeedbackOut: - return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request)) + try: + return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request)) + except HTTPException: + raise + except Exception: + logger.exception( + "feedback approve failed feedback_id=%s admin_id=%s", + feedback_id, admin.id, + ) + raise @router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈") @@ -269,4 +363,13 @@ def reject_feedback( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> FeedbackOut: - return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request)) + try: + return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request)) + except HTTPException: + raise + except Exception: + logger.exception( + "feedback reject failed feedback_id=%s admin_id=%s", + feedback_id, admin.id, + ) + raise diff --git a/app/integrations/vendor_push.py b/app/integrations/vendor_push.py index 745085e..3675418 100644 --- a/app/integrations/vendor_push.py +++ b/app/integrations/vendor_push.py @@ -347,6 +347,25 @@ def _response_summary(resp: Any, parsed: Any | None = None) -> str: return _raw_log_summary(getattr(resp, "text", "")) +def _vendor_response_failed(vendor: str, data: dict[str, Any]) -> bool: + """识别 HTTP 200 中明确的厂商业务失败,避免将失败请求记录成 success。""" + if data.get("error") or data.get("success") is False: + return True + if vendor == "xiaomi": + code = data.get("code") + result = str(data.get("result", "ok")).lower() + return code not in (0, "0", None) or result not in ("ok", "success") + if vendor == "oppo" and data.get("code") is not None: + return int(data["code"]) != 0 + if vendor == "vivo" and data.get("result") is not None: + return int(data["result"]) != 0 + if vendor == "honor" and data.get("code") is not None: + return int(data["code"]) != 200 + if vendor == "huawei" and data.get("code") is not None: + return str(data["code"]) != "80000000" + return False + + def _request_json( method: str, url: str, @@ -410,13 +429,16 @@ def _request_json( _elapsed_ms(started), ) raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e - logger.info( + vendor_failed = _vendor_response_failed(vendor, data) + log = logger.warning if vendor_failed else logger.info + log( "vendor push http completed vendor=%s operation=%s method=%s endpoint=%s " - "outcome=success http_status=%s request=%s response=%s elapsed_ms=%.1f", + "outcome=%s http_status=%s request=%s response=%s elapsed_ms=%.1f", vendor, operation, method, _endpoint_for_log(url), + "vendor_error" if vendor_failed else "success", resp.status_code, request_summary, _response_summary(resp, data), @@ -792,18 +814,21 @@ def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> d def _send_xiaomi_data(token: str, payload: dict[str, str]) -> dict[str, Any]: app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET") + form = { + "registration_id": token, + "restricted_package_name": settings.ANDROID_PACKAGE_NAME, + "payload": json.dumps(payload, ensure_ascii=False), + "pass_through": "1", + "time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000), + } + if settings.XIAOMI_PUSH_CHANNEL_ID.strip(): + form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip() data = _request_form( "POST", settings.XIAOMI_PUSH_SEND_ENDPOINT, vendor="xiaomi", operation="send_data_event", - data={ - "registration_id": token, - "restricted_package_name": settings.ANDROID_PACKAGE_NAME, - "payload": json.dumps(payload, ensure_ascii=False), - "pass_through": "1", - "time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000), - }, + data=form, headers={"Authorization": f"key={app_secret}"}, ) code = data.get("code") diff --git a/app/services/notification_events.py b/app/services/notification_events.py index a4d4f23..0b6e917 100644 --- a/app/services/notification_events.py +++ b/app/services/notification_events.py @@ -85,6 +85,10 @@ def _dispatch( push_vars: dict[str, str] | None = None, ) -> Notification | None: """落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。""" + logger.info( + "notification dispatch started user_id=%s type=%s dedup_key=%s", + user_id, type_key, dedup_key, + ) try: row = notif_repo.create_notification( db, @@ -111,6 +115,10 @@ def _dispatch( logger.exception("rollback after notification failure also failed") return None + logger.info( + "notification created user_id=%s type=%s notification_id=%s dedup_key=%s", + user_id, type_key, row.id, dedup_key, + ) _push_to_user_devices(db, row, push_vars) return row @@ -124,17 +132,44 @@ def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, s extras.update({str(k): str(v) for k, v in (row.extra or {}).items()}) extras["notificationId"] = str(row.id) - for dev in device_repo.list_push_targets(db, user_id=row.user_id): + targets = device_repo.list_push_targets(db, user_id=row.user_id) + logger.info( + "push targets resolved user_id=%s type=%s notification_id=%s target_count=%s", + row.user_id, row.type, row.id, len(targets), + ) + if not targets: + logger.warning( + "push skipped no targets user_id=%s type=%s notification_id=%s", + row.user_id, row.type, row.id, + ) + return + + sent = failed = skipped = data_sent = data_failed = 0 + for dev in targets: vendor = vendor_push.normalize_vendor(dev.push_vendor) if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS: - continue - if vendor_push.missing_settings(vendor): - # 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致) - logger.info( - "skip push (vendor %s not configured) user_id=%s type=%s", - vendor, row.user_id, row.type, + skipped += 1 + logger.warning( + "push target skipped unsupported vendor user_id=%s type=%s " + "notification_id=%s device_id=%s raw_vendor=%s normalized_vendor=%s", + row.user_id, row.type, row.id, dev.device_id, dev.push_vendor, vendor, ) continue + missing = vendor_push.missing_settings(vendor) + if missing: + skipped += 1 + # 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致) + logger.warning( + "push target skipped vendor not configured user_id=%s type=%s " + "notification_id=%s device_id=%s vendor=%s missing_settings=%s", + row.user_id, row.type, row.id, dev.device_id, vendor, missing, + ) + continue + logger.info( + "push send started user_id=%s type=%s notification_id=%s " + "device_id=%s vendor=%s", + row.user_id, row.type, row.id, dev.device_id, vendor, + ) try: response = vendor_push.send_notification( vendor, dev.push_token, title=title, body=body, extras=extras @@ -148,26 +183,44 @@ def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, s row.user_id, row.type, row.id, response, ) logger.info( - "push sent user_id=%s type=%s vendor=%s notification_id=%s", - row.user_id, row.type, vendor, row.id, + "push sent user_id=%s type=%s vendor=%s notification_id=%s device_id=%s", + row.user_id, row.type, vendor, row.id, dev.device_id, ) + sent += 1 except vendor_push.VendorPushError as e: + failed += 1 logger.warning( - "push failed user_id=%s type=%s vendor=%s: %s", row.user_id, row.type, vendor, e + "push failed user_id=%s type=%s vendor=%s notification_id=%s " + "device_id=%s error=%s", + row.user_id, row.type, vendor, row.id, dev.device_id, e, ) try: - vendor_push.send_data_event( + data_response = vendor_push.send_data_event( vendor, dev.push_token, event=vendor_push.DATA_EVENT_NOTIFICATION_CREATED, notification_id=str(row.id), ) + data_sent += 1 + logger.info( + "push data event completed user_id=%s type=%s vendor=%s " + "notification_id=%s device_id=%s response=%s", + row.user_id, row.type, vendor, row.id, dev.device_id, data_response, + ) except vendor_push.VendorPushError as e: + data_failed += 1 # 透传只负责前台铃铛实时刷新,失败不能影响通知栏消息或站内消息。 logger.warning( - "push data event failed user_id=%s type=%s vendor=%s: %s", - row.user_id, row.type, vendor, e, + "push data event failed user_id=%s type=%s vendor=%s notification_id=%s " + "device_id=%s error=%s", + row.user_id, row.type, vendor, row.id, dev.device_id, e, ) + logger.info( + "push dispatch completed user_id=%s type=%s notification_id=%s targets=%s " + "sent=%s failed=%s skipped=%s data_sent=%s data_failed=%s", + row.user_id, row.type, row.id, len(targets), + sent, failed, skipped, data_sent, data_failed, + ) except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛 logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type) -- 2.52.0