Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e66f7fed8c | |||
| 5271d22d24 |
@@ -32,13 +32,6 @@ HEARTBEAT_MONITOR_ENABLED=true
|
||||
HEARTBEAT_TIMEOUT_MINUTES=60
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||
|
||||
# ===== OPPO 推送(厂商通道直连,提现审核通过等交易类通知)=====
|
||||
# OPPO 开放平台注册应用后分配, 服务端鉴权用 AppKey + MasterSecret(≠极光 JG_*)。
|
||||
# 缺任一 → oppo_push_configured=False, 推送静默跳过(不影响审核/打款)。
|
||||
OPPO_PUSH_APP_KEY=
|
||||
OPPO_PUSH_MASTER_SECRET=
|
||||
# 可选(一般不改): OPPO_PUSH_ENABLED=true / OPPO_PUSH_REQUEST_TIMEOUT_SEC=10
|
||||
|
||||
# ===== 短信 (mock 模式) =====
|
||||
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
|
||||
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""device_liveness 加 oppo_register_id 列(OPPO 直连推送目标)
|
||||
|
||||
提现审核通过等交易类通知走 OPPO 官方推送直连(≠极光)。客户端集成 OPPO SDK 后拿到 OPPO
|
||||
registerID, 经 device/register + heartbeat 上报, 存本列; 与极光 registration_id 并列,
|
||||
一台 OPPO 设备两套 token 并存。
|
||||
|
||||
Revision ID: device_oppo_register_id
|
||||
Revises: comparison_llm_cost
|
||||
Create Date: 2026-07-15 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'device_oppo_register_id'
|
||||
down_revision: Union[str, Sequence[str], None] = 'comparison_llm_cost'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('oppo_register_id', sa.String(length=128), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
|
||||
batch_op.drop_column('oppo_register_id')
|
||||
@@ -37,7 +37,6 @@ from app.integrations import wxpay
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import app_config
|
||||
from app.repositories import wallet as wallet_repo
|
||||
from app.services import push_notify
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/withdraws",
|
||||
@@ -316,9 +315,6 @@ def bulk_approve_withdraws(
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=True, status=order.status)
|
||||
)
|
||||
# 审核通过 → OPPO 推送(fire-and-forget, 不阻塞批量审核; status=failed 已退款, 不推)
|
||||
if order.status != "failed":
|
||||
push_notify.notify_withdraw_approved_async(order.user_id)
|
||||
except wallet_repo.WithdrawOrderNotFound:
|
||||
db.rollback()
|
||||
results.append(
|
||||
@@ -442,9 +438,6 @@ def approve_withdraw_order(
|
||||
},
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
# 审核通过 → OPPO 推送(fire-and-forget, 不阻塞审核响应; status=failed 是打款失败已退款, 不推)
|
||||
if order.status != "failed":
|
||||
push_notify.notify_withdraw_approved_async(order.user_id)
|
||||
return WithdrawOrderOut.model_validate(order)
|
||||
|
||||
|
||||
|
||||
+18
-5
@@ -14,18 +14,24 @@ import logging
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import wx_poc_signal
|
||||
from app.repositories import device as device_repo
|
||||
from app.schemas.device import (
|
||||
DeviceOut,
|
||||
DeviceRegisterRequest,
|
||||
HeartbeatRequest,
|
||||
HeartbeatResponse,
|
||||
LivenessAckRequest,
|
||||
LivenessOut,
|
||||
OkResponse,
|
||||
PendingCompare,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.device")
|
||||
|
||||
# PoC:源平台代号 → Android 包名(前端 launch 用)。当前只用美团。
|
||||
_SOURCE_PACKAGES = {"meituan": "com.sankuai.meituan"}
|
||||
|
||||
router = APIRouter(prefix="/api/v1/device", tags=["device"])
|
||||
|
||||
|
||||
@@ -40,7 +46,6 @@ def register_device(
|
||||
user_id=user.id,
|
||||
device_id=req.device_id,
|
||||
registration_id=req.registration_id,
|
||||
oppo_register_id=req.oppo_register_id,
|
||||
platform=req.platform,
|
||||
app_version=req.app_version,
|
||||
)
|
||||
@@ -53,21 +58,29 @@ def register_device(
|
||||
return DeviceOut.model_validate(device)
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=OkResponse, summary="上报心跳")
|
||||
@router.post("/heartbeat", response_model=HeartbeatResponse, summary="上报心跳")
|
||||
def report_heartbeat(
|
||||
req: HeartbeatRequest,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> OkResponse:
|
||||
) -> HeartbeatResponse:
|
||||
device_repo.touch_heartbeat(
|
||||
db,
|
||||
user_id=user.id,
|
||||
device_id=req.device_id,
|
||||
accessibility_enabled=req.accessibility_enabled,
|
||||
registration_id=req.registration_id,
|
||||
oppo_register_id=req.oppo_register_id,
|
||||
)
|
||||
return OkResponse()
|
||||
# PoC:该设备有"待比价"信号则带回(只对写死的测试设备生效, 取走即清、只弹一次)
|
||||
source = wx_poc_signal.pop_pending(req.device_id)
|
||||
pending = None
|
||||
if source:
|
||||
pending = PendingCompare(
|
||||
source_platform=source,
|
||||
source_package=_SOURCE_PACKAGES.get(source, ""),
|
||||
)
|
||||
logger.info("heartbeat 下发比价信号 device=%s source=%s", req.device_id, source)
|
||||
return HeartbeatResponse(pending_compare=pending)
|
||||
|
||||
|
||||
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
|
||||
|
||||
@@ -19,6 +19,7 @@ import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.core import wx_poc_signal
|
||||
from app.core.config import settings
|
||||
from app.integrations import wx_mp_crypto
|
||||
|
||||
@@ -85,6 +86,11 @@ async def _handle_message(root: ET.Element) -> None:
|
||||
logger.info(
|
||||
"wx_mp 收到图片: openid=%s media_id=%s pic_url=%s", openid, media_id, pic_url
|
||||
)
|
||||
# PoC: 写死"该测试设备要从美团比价", 下次心跳带回前端弹选平台窗
|
||||
poc_dev = settings.WX_POC_TEST_DEVICE_ID
|
||||
if poc_dev:
|
||||
wx_poc_signal.set_pending(poc_dev, "meituan")
|
||||
logger.info("wx_mp PoC: 已给测试设备 %s 打比价信号 source=meituan", poc_dev)
|
||||
await _download(pic_url, openid, media_id)
|
||||
else:
|
||||
logger.info("wx_mp 收到消息(暂忽略): openid=%s type=%s", openid, msg_type)
|
||||
|
||||
+5
-28
@@ -71,34 +71,6 @@ class Settings(BaseSettings):
|
||||
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
|
||||
|
||||
# ===== OPPO 推送(厂商通道直连) =====
|
||||
# 直连 OPPO PUSH 服务端 API(api.push.oppomobile.com), 不经极光。凭证来自 OPPO 开放平台
|
||||
# 注册应用后分配(≠极光 JG_*)。两步鉴权: 先 /server/v1/auth 用 sha256(app_key+timestamp+
|
||||
# master_secret) 换 auth_token(缓存 24h), 再带 auth_token 调 /message/notification/unicast
|
||||
# 单推。提现审核通过等交易类通知走这条。未配凭证 → oppo_push_configured=False, 发送方静默
|
||||
# 跳过(不连累审核/打款)。
|
||||
OPPO_PUSH_ENABLED: bool = True # 总开关(配了凭证也能置 False 全局停发)
|
||||
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_UNICAST_ENDPOINT: str = (
|
||||
"https://api.push.oppomobile.com/server/v1/message/notification/unicast"
|
||||
)
|
||||
OPPO_PUSH_REQUEST_TIMEOUT_SEC: int = 10
|
||||
# 通知点击行为(OPPO click_action_type; 0=启动应用首页)。交易类通知点开进首页即可。
|
||||
OPPO_PUSH_CLICK_ACTION_TYPE: int = 0
|
||||
# 通知渠道 id(Android 8+ NotificationChannel): 【客户端创建的渠道】+【OPPO 管理台登记】+
|
||||
# 【服务端此值】三方必须一致, 否则真机可能不展示。客户端 OppoPushHelper.CHANNEL_ID 同值。
|
||||
OPPO_PUSH_CHANNEL_ID: str = "push_oplus_category_content"
|
||||
# OPPO 新消息分类(Android 12+ 触达; 提现属 ACCOUNT 账号资产变化)。默认空=不带该字段——
|
||||
# 它依赖 OPPO 管理台的消息分类资质, 未开通就带上可能反被限制, 确认资质后再填(如 "ACCOUNT")。
|
||||
OPPO_PUSH_CATEGORY: str = ""
|
||||
|
||||
@property
|
||||
def oppo_push_configured(self) -> bool:
|
||||
"""OPPO 推送凭证是否齐备(app_key + master_secret)。缺任一 → 发送方静默跳过。"""
|
||||
return bool(self.OPPO_PUSH_APP_KEY and self.OPPO_PUSH_MASTER_SECRET)
|
||||
|
||||
# ===== 短信 =====
|
||||
SMS_MOCK: bool = True
|
||||
SMS_CODE_TTL_SEC: int = 300
|
||||
@@ -170,6 +142,11 @@ class Settings(BaseSettings):
|
||||
WX_MP_TOKEN: str = "" # 公众平台"服务器配置"的 Token(URL 验签 + 消息验签)
|
||||
WX_MP_AES_KEY: str = "" # EncodingAESKey(43 位, 消息 AES-256-CBC 加解密)
|
||||
|
||||
# ===== 微信截图比价 PoC(临时验证链路, 验证后删) =====
|
||||
# 只对这台写死的测试设备下发"从美团比价"信号;空=不触发任何设备(部署上线零风险)。
|
||||
# 收图 → 给此 device_id 打 pending → 该设备下次心跳带回 → 前端弹选平台窗。
|
||||
WX_POC_TEST_DEVICE_ID: str = ""
|
||||
|
||||
@property
|
||||
def wx_mp_configured(self) -> bool:
|
||||
"""服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。"""
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""微信截图比价 PoC:进程内存的"待比价"信号(单 worker 够用, 重启即失效, PoC 可接受)。
|
||||
|
||||
收图端点 set_pending(device_id, source) → 该设备下次心跳 pop_pending 取走并清除 →
|
||||
心跳响应带回 → 前端弹选平台窗。只对 settings.WX_POC_TEST_DEVICE_ID 写入, 不碰其他设备。
|
||||
后续接真识别 / openid↔device 绑定时整体替换本模块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
_lock = threading.Lock()
|
||||
_pending: dict[str, str] = {} # device_id -> source_platform(如 "meituan")
|
||||
|
||||
|
||||
def set_pending(device_id: str, source_platform: str) -> None:
|
||||
if not device_id:
|
||||
return
|
||||
with _lock:
|
||||
_pending[device_id] = source_platform
|
||||
|
||||
|
||||
def pop_pending(device_id: str) -> str | None:
|
||||
"""取出并清除该设备的待比价信号;无则 None。心跳每帧调, 取到即消费(只弹一次)。"""
|
||||
if not device_id:
|
||||
return None
|
||||
with _lock:
|
||||
return _pending.pop(device_id, None)
|
||||
@@ -1,183 +0,0 @@
|
||||
"""OPPO 推送服务端直连(厂商通道)。
|
||||
|
||||
不经极光, 直接调 OPPO PUSH 服务端 API(api.push.oppomobile.com)。提现审核通过等交易类
|
||||
通知走这条。凭证来自 OPPO 开放平台(app_key/master_secret, 见 settings.OPPO_PUSH_*)。
|
||||
|
||||
两步鉴权(OPPO 协议):
|
||||
1. POST /server/v1/auth 用 sign=sha256(app_key+timestamp+master_secret) 换 auth_token
|
||||
(有效期 24h)。token 进程内缓存复用, 到期前自动重取。
|
||||
2. POST /server/v1/message/notification/unicast header 带 auth_token, 按单个
|
||||
registration_id(OPPO 的 registerID) 定向推送一条通知栏消息。
|
||||
|
||||
发送 best-effort: 未配凭证 / 网络失败 / OPPO 返错都抛 OppoPushError, 由调用方(push_notify)
|
||||
吞掉只记日志, 绝不连累审核/打款主流程。风格对齐 integrations/sms.py(httpx 同步 + 未配降级)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.oppo_push")
|
||||
|
||||
# OPPO target_type 枚举: 2 = 按 registration_id 单推
|
||||
_TARGET_TYPE_REGISTRATION_ID = 2
|
||||
# auth_token 官方有效期 24h; 提前 1h 视为过期重取, 留刷新余量(避免边界请求踩到刚过期)
|
||||
_TOKEN_TTL_SEC = 24 * 3600
|
||||
_TOKEN_REFRESH_MARGIN_SEC = 3600
|
||||
|
||||
|
||||
class OppoPushError(Exception):
|
||||
"""OPPO 推送失败(未配置 / 鉴权失败 / 网络错误 / OPPO 返错)。调用方 best-effort 吞。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CachedToken:
|
||||
token: str
|
||||
expires_at: float # epoch 秒(= 取到 token 的时刻 + 24h)
|
||||
|
||||
|
||||
# 进程内存 token 缓存(单 worker 有效; 多 worker 各自缓存, 各自换 token, OPPO 侧无副作用)
|
||||
_token_cache: _CachedToken | None = None
|
||||
_token_lock = Lock()
|
||||
|
||||
|
||||
def _sign(app_key: str, timestamp_ms: int, master_secret: str) -> str:
|
||||
"""OPPO 鉴权签名: sha256(app_key + timestamp + master_secret) 十六进制小写。"""
|
||||
raw = f"{app_key}{timestamp_ms}{master_secret}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _parse_oppo_response(resp: httpx.Response, phase: str) -> dict | None:
|
||||
"""解析 OPPO 统一响应 {code, message, data}: code==0 返 data, 否则抛 OppoPushError。"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception as e:
|
||||
raise OppoPushError(
|
||||
f"OPPO {phase} 响应非 JSON(http={resp.status_code}): {resp.text[:200]}"
|
||||
) from e
|
||||
if body.get("code") != 0:
|
||||
raise OppoPushError(
|
||||
f"OPPO {phase} 失败 code={body.get('code')} message={body.get('message')!r}"
|
||||
)
|
||||
return body.get("data")
|
||||
|
||||
|
||||
def _fetch_auth_token() -> str:
|
||||
"""调 /server/v1/auth 换 auth_token。失败抛 OppoPushError。"""
|
||||
timestamp_ms = int(time.time() * 1000) # 毫秒时间戳(OPPO 允许 ±10 分钟误差)
|
||||
sign = _sign(
|
||||
settings.OPPO_PUSH_APP_KEY, timestamp_ms, settings.OPPO_PUSH_MASTER_SECRET
|
||||
)
|
||||
try:
|
||||
resp = httpx.post(
|
||||
settings.OPPO_PUSH_AUTH_ENDPOINT,
|
||||
data={
|
||||
"app_key": settings.OPPO_PUSH_APP_KEY,
|
||||
"sign": sign,
|
||||
"timestamp": timestamp_ms,
|
||||
},
|
||||
timeout=settings.OPPO_PUSH_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise OppoPushError(f"OPPO auth 网络错误: {e}") from e
|
||||
|
||||
data = _parse_oppo_response(resp, "auth")
|
||||
token = (data or {}).get("auth_token")
|
||||
if not token:
|
||||
raise OppoPushError(f"OPPO auth 未返回 auth_token: {resp.text[:200]}")
|
||||
return token
|
||||
|
||||
|
||||
def _get_auth_token(force_refresh: bool = False) -> str:
|
||||
"""取 auth_token(缓存优先; 到期或 force_refresh 才重取)。线程安全。"""
|
||||
global _token_cache
|
||||
now = time.time()
|
||||
with _token_lock:
|
||||
cache = _token_cache
|
||||
if (
|
||||
not force_refresh
|
||||
and cache is not None
|
||||
and now < cache.expires_at - _TOKEN_REFRESH_MARGIN_SEC
|
||||
):
|
||||
return cache.token
|
||||
token = _fetch_auth_token()
|
||||
_token_cache = _CachedToken(token=token, expires_at=now + _TOKEN_TTL_SEC)
|
||||
return token
|
||||
|
||||
|
||||
def send_notification(
|
||||
registration_id: str,
|
||||
title: str,
|
||||
content: str,
|
||||
*,
|
||||
click_action_type: int | None = None,
|
||||
) -> str:
|
||||
"""向单个 OPPO registerID 推一条通知栏消息, 返回 OPPO message_id。
|
||||
|
||||
失败(未配置 / 鉴权 / 网络 / OPPO 返错)一律抛 OppoPushError, 由调用方吞。
|
||||
unicast 首次失败时强刷一次 token 重试(覆盖 token 被 OPPO 提前失效的情况); 网络错误不重试。
|
||||
"""
|
||||
if not settings.OPPO_PUSH_ENABLED:
|
||||
raise OppoPushError("OPPO 推送总开关关闭(OPPO_PUSH_ENABLED=false)")
|
||||
if not settings.oppo_push_configured:
|
||||
raise OppoPushError("OPPO 推送未配置(缺 OPPO_PUSH_APP_KEY/OPPO_PUSH_MASTER_SECRET)")
|
||||
if not registration_id:
|
||||
raise OppoPushError("registration_id 为空")
|
||||
|
||||
if click_action_type is None:
|
||||
click_action_type = settings.OPPO_PUSH_CLICK_ACTION_TYPE
|
||||
notification = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"click_action_type": click_action_type,
|
||||
}
|
||||
# channel_id: Android 8+ 通知渠道(客户端已建同名渠道 + OPPO 管理台登记, 三方一致才展示)。
|
||||
if settings.OPPO_PUSH_CHANNEL_ID:
|
||||
notification["channel_id"] = settings.OPPO_PUSH_CHANNEL_ID
|
||||
# category: OPPO 新消息分类(默认空不带; 依赖管理台资质, 见 config 注释)。
|
||||
if settings.OPPO_PUSH_CATEGORY:
|
||||
notification["category"] = settings.OPPO_PUSH_CATEGORY
|
||||
message = {
|
||||
"target_type": _TARGET_TYPE_REGISTRATION_ID,
|
||||
"target_value": registration_id,
|
||||
"notification": notification,
|
||||
}
|
||||
# OPPO unicast body 是 form 编码, message 字段为 JSON 字符串
|
||||
payload = {"message": json.dumps(message, ensure_ascii=False)}
|
||||
|
||||
last_err: OppoPushError | None = None
|
||||
for attempt in range(2):
|
||||
token = _get_auth_token(force_refresh=(attempt == 1))
|
||||
try:
|
||||
resp = httpx.post(
|
||||
settings.OPPO_PUSH_UNICAST_ENDPOINT,
|
||||
data=payload,
|
||||
headers={"auth_token": token},
|
||||
timeout=settings.OPPO_PUSH_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
# 网络错误与 token 无关, 不强刷重试
|
||||
raise OppoPushError(f"OPPO unicast 网络错误: {e}") from e
|
||||
try:
|
||||
data = _parse_oppo_response(resp, "unicast")
|
||||
except OppoPushError as e:
|
||||
last_err = e
|
||||
if attempt == 0:
|
||||
logger.warning("[OPPO] unicast 失败(%s), 强刷 token 重试一次", e)
|
||||
continue
|
||||
raise
|
||||
message_id = (data or {}).get("message_id", "")
|
||||
logger.info(
|
||||
"[OPPO] 推送成功 regId=%s… message_id=%s", registration_id[:12], message_id
|
||||
)
|
||||
return message_id
|
||||
# 理论到不了(第二次失败已 raise); 防御性收尾
|
||||
raise last_err or OppoPushError("OPPO unicast 重试后仍失败")
|
||||
@@ -44,9 +44,6 @@ class DeviceLiveness(Base):
|
||||
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
|
||||
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
|
||||
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# OPPO 官方推送 SDK(HeytapPushManager)的 registerID; 直连 OPPO 厂商通道用(≠极光 registration_id)。
|
||||
# 一台 OPPO 设备两套 token 并存: 极光走极光自有/其它厂商通道, 这个走 OPPO 直连(提现审核通过等交易通知)。
|
||||
oppo_register_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
|
||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
|
||||
@@ -22,18 +22,16 @@ def register_or_update(
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
registration_id: str | None,
|
||||
oppo_register_id: str | None = None,
|
||||
platform: str = "android",
|
||||
app_version: str | None = None,
|
||||
) -> DeviceLiveness:
|
||||
"""注册设备或更新其 registration_id / oppo_register_id / 元信息。upsert by (user_id, device_id)。"""
|
||||
"""注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。"""
|
||||
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,
|
||||
oppo_register_id=oppo_register_id,
|
||||
platform=platform or "android",
|
||||
app_version=app_version,
|
||||
)
|
||||
@@ -41,8 +39,6 @@ def register_or_update(
|
||||
else:
|
||||
if registration_id:
|
||||
device.registration_id = registration_id
|
||||
if oppo_register_id:
|
||||
device.oppo_register_id = oppo_register_id
|
||||
if platform:
|
||||
device.platform = platform
|
||||
if app_version:
|
||||
@@ -59,13 +55,11 @@ def touch_heartbeat(
|
||||
device_id: str,
|
||||
accessibility_enabled: bool,
|
||||
registration_id: str | None,
|
||||
oppo_register_id: str | None = None,
|
||||
) -> DeviceLiveness:
|
||||
"""处理一次心跳(心跳也能自注册)。
|
||||
|
||||
service 心跳或 accessibility_enabled=true 时,刷新存活并把状态机重置回 alive、
|
||||
清掉 notified_at(掉线恢复 → 下次再断才会再推一条)。registration_id / oppo_register_id
|
||||
非空时顺带更新(客户端拿到 push token 后每次心跳带上, 后端最终一致)。
|
||||
清掉 notified_at(掉线恢复 → 下次再断才会再推一条)。
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
device = _get(db, user_id=user_id, device_id=device_id)
|
||||
@@ -75,8 +69,6 @@ def touch_heartbeat(
|
||||
|
||||
if registration_id:
|
||||
device.registration_id = registration_id
|
||||
if oppo_register_id:
|
||||
device.oppo_register_id = oppo_register_id
|
||||
device.last_report_protection_on = accessibility_enabled
|
||||
|
||||
if accessibility_enabled:
|
||||
@@ -126,25 +118,6 @@ def get_device(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness |
|
||||
return _get(db, user_id=user_id, device_id=device_id)
|
||||
|
||||
|
||||
def list_oppo_register_ids_by_user(db: Session, *, user_id: int) -> list[str]:
|
||||
"""取某用户所有设备已登记的 OPPO registerID(去重保序, 非空)。
|
||||
|
||||
提现审核通过推送用: 一个用户可能多台设备, 每台都推; 没 OPPO token 的设备(非 OPPO 机 /
|
||||
未集成 OPPO SDK)自然不在列。空列表 = 该用户没有可推的 OPPO 设备。
|
||||
"""
|
||||
stmt = select(DeviceLiveness.oppo_register_id).where(
|
||||
DeviceLiveness.user_id == user_id,
|
||||
DeviceLiveness.oppo_register_id.is_not(None),
|
||||
)
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for rid in db.execute(stmt).scalars().all():
|
||||
if rid and rid not in seen:
|
||||
seen.add(rid)
|
||||
out.append(rid)
|
||||
return out
|
||||
|
||||
|
||||
def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None:
|
||||
"""客户端已弹过「开启自启动」引导 → 清「待提醒」标记(幂等; 下次真掉线 worker 再置)。"""
|
||||
device = _get(db, user_id=user_id, device_id=device_id)
|
||||
|
||||
+14
-3
@@ -9,7 +9,6 @@ from pydantic import BaseModel, ConfigDict
|
||||
class DeviceRegisterRequest(BaseModel):
|
||||
device_id: str
|
||||
registration_id: str | None = None
|
||||
oppo_register_id: str | None = None
|
||||
platform: str = "android"
|
||||
app_version: str | None = None
|
||||
|
||||
@@ -19,7 +18,6 @@ class HeartbeatRequest(BaseModel):
|
||||
source: str = "service" # service | app
|
||||
accessibility_enabled: bool = True
|
||||
registration_id: str | None = None
|
||||
oppo_register_id: str | None = None
|
||||
|
||||
|
||||
class DeviceOut(BaseModel):
|
||||
@@ -28,7 +26,6 @@ class DeviceOut(BaseModel):
|
||||
id: int
|
||||
device_id: str
|
||||
registration_id: str | None
|
||||
oppo_register_id: str | None
|
||||
ever_protected: bool
|
||||
liveness_state: str
|
||||
last_heartbeat_at: datetime | None
|
||||
@@ -39,6 +36,20 @@ class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class PendingCompare(BaseModel):
|
||||
"""PoC:后端通过心跳下发的"用户要从某源平台比价"信号。前端据此弹选平台窗 + launch 源平台。"""
|
||||
source_platform: str # pricebot 源平台代号(PoC 写死 "meituan")
|
||||
source_package: str # 源平台 Android 包名(前端 launch 用)
|
||||
|
||||
|
||||
class HeartbeatResponse(BaseModel):
|
||||
"""心跳响应。常规只回 ok;PoC 期带回 pending_compare 触发比价(exclude_none 省略 null)。"""
|
||||
model_config = ConfigDict()
|
||||
|
||||
ok: bool = True
|
||||
pending_compare: PendingCompare | None = None
|
||||
|
||||
|
||||
class LivenessOut(BaseModel):
|
||||
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
|
||||
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state 等)。从未注册过 → 默认 False(无告警)。"""
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""推送通知编排(查设备 push token → 调厂商推送)。
|
||||
|
||||
薄编排层: repositories(取 token) + integrations(发送) 的粘合, 供 api / admin router 调用。
|
||||
所有发送 best-effort —— 失败只 log, 绝不抛给调用方(不连累审核/打款等主流程)。
|
||||
|
||||
当前只有 OPPO 直连一条通道(提现审核通过通知)。后续加华为/vivo 等厂商时, 在这里按设备
|
||||
token 做通道分发, 上层业务函数(notify_withdraw_approved 等)不感知通道差异。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations import oppo_push
|
||||
from app.integrations.oppo_push import OppoPushError
|
||||
from app.repositories import device as device_repo
|
||||
|
||||
logger = logging.getLogger("shagua.push_notify")
|
||||
|
||||
_WITHDRAW_APPROVED_TITLE = "提现审核通过"
|
||||
_WITHDRAW_APPROVED_CONTENT = "您的提现申请已审核通过,款项将很快到账,请留意微信零钱。"
|
||||
|
||||
|
||||
def notify_withdraw_approved(db: Session, *, user_id: int) -> int:
|
||||
"""提现审核通过 → 给该用户所有 OPPO 设备各推一条通知。返回成功推送的设备数。
|
||||
|
||||
best-effort: 未配 OPPO / 无 OPPO 设备 / 单设备发送失败都不抛, 只 log。审核主流程绝不受影响。
|
||||
调用方仍应包一层 try/except 兜底本函数自身的意外异常(如 DB 查询失败)。
|
||||
"""
|
||||
reg_ids = device_repo.list_oppo_register_ids_by_user(db, user_id=user_id)
|
||||
if not reg_ids:
|
||||
logger.info("[push] withdraw_approved user_id=%s 无 OPPO 设备, 跳过", user_id)
|
||||
return 0
|
||||
|
||||
sent = 0
|
||||
for rid in reg_ids:
|
||||
try:
|
||||
oppo_push.send_notification(
|
||||
rid, _WITHDRAW_APPROVED_TITLE, _WITHDRAW_APPROVED_CONTENT
|
||||
)
|
||||
sent += 1
|
||||
except OppoPushError as e:
|
||||
logger.warning(
|
||||
"[push] withdraw_approved user_id=%s regId=%s… 发送失败: %s",
|
||||
user_id, rid[:12], e,
|
||||
)
|
||||
logger.info(
|
||||
"[push] withdraw_approved user_id=%s 推送 %d/%d 台 OPPO 设备",
|
||||
user_id, sent, len(reg_ids),
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
def notify_withdraw_approved_async(user_id: int) -> None:
|
||||
"""fire-and-forget 版: 后台守护线程新开 session 发推送, 不阻塞审核响应(对齐 best-effort)。
|
||||
|
||||
审核 approve 已同步调微信转账, 推送不该再把外部 API 耗时叠进响应(多设备/OPPO 慢时最坏
|
||||
N×timeout)。线程内必须新开 SessionLocal —— 请求级 session 在响应返回后即关, 不能跨线程用。
|
||||
起线程失败也只 log, 绝不连累审核。
|
||||
"""
|
||||
def _run() -> None:
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
notify_withdraw_approved(db, user_id=user_id)
|
||||
except Exception: # noqa: BLE001 - 后台线程绝不抛
|
||||
logger.warning("[push] withdraw_approved async 异常 user_id=%s", user_id, exc_info=True)
|
||||
|
||||
try:
|
||||
threading.Thread(target=_run, name=f"oppo-push-{user_id}", daemon=True).start()
|
||||
except Exception: # noqa: BLE001 - 起线程失败也绝不连累审核
|
||||
logger.warning("[push] withdraw_approved async 起线程失败 user_id=%s", user_id, exc_info=True)
|
||||
@@ -476,81 +476,3 @@ def test_withdraw_reconcile(admin_client: TestClient, finance_token: str, monkey
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "checked" in r.json() and "resolved" in r.json()
|
||||
|
||||
|
||||
# ===== 提现审核通过 → OPPO 推送挂钩 =====
|
||||
|
||||
def test_approve_triggers_oppo_push(
|
||||
admin_client: TestClient, finance_token: str, monkeypatch
|
||||
) -> None:
|
||||
"""审核通过(非 failed)后触发一次 OPPO 推送, 带被审核单的 user_id。"""
|
||||
uid = _seed_user("13900000021")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(WithdrawOrder(
|
||||
user_id=uid, out_bill_no="pushapprove0001", amount_cents=100, status="reviewing"
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
from app.admin.routers import withdraw as withdraw_router
|
||||
|
||||
# 绕过真实微信转账: approve 直接把单置 pending 返回(不发 wxpay)
|
||||
def _fake_approve(db, obn):
|
||||
o = db.execute(
|
||||
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == obn)
|
||||
).scalar_one()
|
||||
o.status = "pending"
|
||||
db.commit()
|
||||
return o
|
||||
|
||||
monkeypatch.setattr(withdraw_router.wallet_repo, "approve_withdraw", _fake_approve)
|
||||
pushed: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
withdraw_router.push_notify, "notify_withdraw_approved_async",
|
||||
lambda user_id: pushed.append(user_id),
|
||||
)
|
||||
r = admin_client.post(
|
||||
"/admin/api/withdraws/pushapprove0001/approve", headers=_auth(finance_token)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert pushed == [uid]
|
||||
|
||||
|
||||
def test_approve_failed_does_not_push(
|
||||
admin_client: TestClient, finance_token: str, monkeypatch
|
||||
) -> None:
|
||||
"""打款直接失败(status=failed, 已退款)不推"审核通过"。"""
|
||||
uid = _seed_user("13900000022")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(WithdrawOrder(
|
||||
user_id=uid, out_bill_no="pushapprove0002", amount_cents=100, status="reviewing"
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
from app.admin.routers import withdraw as withdraw_router
|
||||
|
||||
def _fake_approve_failed(db, obn):
|
||||
o = db.execute(
|
||||
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == obn)
|
||||
).scalar_one()
|
||||
o.status = "failed"
|
||||
o.fail_reason = "余额不足"
|
||||
db.commit()
|
||||
return o
|
||||
|
||||
monkeypatch.setattr(withdraw_router.wallet_repo, "approve_withdraw", _fake_approve_failed)
|
||||
pushed: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
withdraw_router.push_notify, "notify_withdraw_approved_async",
|
||||
lambda user_id: pushed.append(user_id),
|
||||
)
|
||||
r = admin_client.post(
|
||||
"/admin/api/withdraws/pushapprove0002/approve", headers=_auth(finance_token)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert pushed == []
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
"""oppo_push 集成层单测:未配降级 / auth+unicast 正常流程 / token 缓存 / OPPO 错误码抛错。
|
||||
|
||||
httpx 全 monkeypatch, 不发真实网络。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.integrations import oppo_push
|
||||
from app.integrations.oppo_push import OppoPushError
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status_code: int, payload: dict) -> None:
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = json.dumps(payload)
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
def _configure(monkeypatch) -> None:
|
||||
"""配齐凭证 + 清 token 缓存(避免跨用例污染)。"""
|
||||
monkeypatch.setattr(oppo_push.settings, "OPPO_PUSH_ENABLED", True)
|
||||
monkeypatch.setattr(oppo_push.settings, "OPPO_PUSH_APP_KEY", "ak")
|
||||
monkeypatch.setattr(oppo_push.settings, "OPPO_PUSH_MASTER_SECRET", "ms")
|
||||
oppo_push._token_cache = None
|
||||
|
||||
|
||||
def test_not_configured_raises(monkeypatch) -> None:
|
||||
monkeypatch.setattr(oppo_push.settings, "OPPO_PUSH_APP_KEY", "")
|
||||
monkeypatch.setattr(oppo_push.settings, "OPPO_PUSH_MASTER_SECRET", "")
|
||||
oppo_push._token_cache = None
|
||||
with pytest.raises(OppoPushError):
|
||||
oppo_push.send_notification("regid", "t", "c")
|
||||
|
||||
|
||||
def test_disabled_raises(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
monkeypatch.setattr(oppo_push.settings, "OPPO_PUSH_ENABLED", False)
|
||||
with pytest.raises(OppoPushError):
|
||||
oppo_push.send_notification("regid", "t", "c")
|
||||
|
||||
|
||||
def test_success_flow(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
calls: list[tuple] = []
|
||||
|
||||
def _fake_post(url, **kw):
|
||||
calls.append((url, kw))
|
||||
if url == oppo_push.settings.OPPO_PUSH_AUTH_ENDPOINT:
|
||||
return _FakeResp(200, {"code": 0, "message": "success", "data": {"auth_token": "TOK"}})
|
||||
return _FakeResp(200, {"code": 0, "message": "success", "data": {"message_id": "MID"}})
|
||||
|
||||
monkeypatch.setattr(oppo_push.httpx, "post", _fake_post)
|
||||
mid = oppo_push.send_notification("regid123", "标题", "内容")
|
||||
assert mid == "MID"
|
||||
# 先 auth 再 unicast, unicast header 带 auth_token
|
||||
assert calls[0][0] == oppo_push.settings.OPPO_PUSH_AUTH_ENDPOINT
|
||||
assert calls[1][0] == oppo_push.settings.OPPO_PUSH_UNICAST_ENDPOINT
|
||||
assert calls[1][1]["headers"]["auth_token"] == "TOK"
|
||||
# unicast body 的 message 是 JSON 字符串, 含 target_value / notification
|
||||
msg = json.loads(calls[1][1]["data"]["message"])
|
||||
assert msg["target_value"] == "regid123"
|
||||
assert msg["notification"]["title"] == "标题"
|
||||
assert msg["notification"]["content"] == "内容"
|
||||
# channel_id 必带(默认 shagua_wallet); category 默认空 → 不带
|
||||
assert msg["notification"]["channel_id"] == "push_oplus_category_content"
|
||||
assert "category" not in msg["notification"]
|
||||
|
||||
|
||||
def test_auth_token_cached(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
auth_hits: list[int] = []
|
||||
|
||||
def _fake_post(url, **kw):
|
||||
if url == oppo_push.settings.OPPO_PUSH_AUTH_ENDPOINT:
|
||||
auth_hits.append(1)
|
||||
return _FakeResp(200, {"code": 0, "data": {"auth_token": "TOK"}})
|
||||
return _FakeResp(200, {"code": 0, "data": {"message_id": "MID"}})
|
||||
|
||||
monkeypatch.setattr(oppo_push.httpx, "post", _fake_post)
|
||||
oppo_push.send_notification("r", "t", "c")
|
||||
oppo_push.send_notification("r", "t", "c")
|
||||
assert len(auth_hits) == 1 # 第二次复用缓存 token, 不再换 auth
|
||||
|
||||
|
||||
def test_oppo_error_raises(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
|
||||
def _fake_post(url, **kw):
|
||||
if url == oppo_push.settings.OPPO_PUSH_AUTH_ENDPOINT:
|
||||
return _FakeResp(200, {"code": 0, "data": {"auth_token": "TOK"}})
|
||||
return _FakeResp(200, {"code": -2, "message": "invalid target"})
|
||||
|
||||
monkeypatch.setattr(oppo_push.httpx, "post", _fake_post)
|
||||
with pytest.raises(OppoPushError):
|
||||
oppo_push.send_notification("r", "t", "c")
|
||||
@@ -1,70 +0,0 @@
|
||||
"""push_notify.notify_withdraw_approved 单测:多 OPPO 设备全推 / 无设备返 0 / 发送失败吞错。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations.oppo_push import OppoPushError
|
||||
from app.repositories import device as device_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.services import push_notify
|
||||
|
||||
|
||||
def _seed_user(phone: str) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return user_repo.upsert_user_for_login(db, phone=phone, register_channel="sms").id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _add_oppo_device(user_id: int, device_id: str, oppo_reg: str) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
device_repo.register_or_update(
|
||||
db, user_id=user_id, device_id=device_id,
|
||||
registration_id=None, oppo_register_id=oppo_reg,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_notify_no_oppo_device_returns_zero() -> None:
|
||||
uid = _seed_user("13911100001")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert push_notify.notify_withdraw_approved(db, user_id=uid) == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_notify_pushes_all_oppo_devices(monkeypatch) -> None:
|
||||
uid = _seed_user("13911100002")
|
||||
_add_oppo_device(uid, "dev_a", "OPPO_REG_A")
|
||||
_add_oppo_device(uid, "dev_b", "OPPO_REG_B")
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
push_notify.oppo_push, "send_notification",
|
||||
lambda rid, title, content, **kw: sent.append(rid) or "msgid",
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
n = push_notify.notify_withdraw_approved(db, user_id=uid)
|
||||
finally:
|
||||
db.close()
|
||||
assert n == 2
|
||||
assert set(sent) == {"OPPO_REG_A", "OPPO_REG_B"}
|
||||
|
||||
|
||||
def test_notify_swallows_send_error(monkeypatch) -> None:
|
||||
uid = _seed_user("13911100003")
|
||||
_add_oppo_device(uid, "dev_c", "OPPO_REG_C")
|
||||
|
||||
def _boom(rid, title, content, **kw):
|
||||
raise OppoPushError("simulated failure")
|
||||
|
||||
monkeypatch.setattr(push_notify.oppo_push, "send_notification", _boom)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 发送失败被吞, 不抛; 成功数为 0
|
||||
assert push_notify.notify_withdraw_approved(db, user_id=uid) == 0
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user