Compare commits

..

4 Commits

Author SHA1 Message Date
marco 1a0f0e5ced feat(wx): 微信服务号消息接收回调 /wx/mp/callback(截图比价入口第一步)
- GET 接入验证(校验 signature → 原样返回 echostr),供公众平台配置服务器 URL
- POST 收消息(安全模式:msg_signature 验签 + AES-256-CBC 解密),图片消息用 PicUrl 落盘
- config 加 WX_MP_TOKEN / WX_MP_AES_KEY + wx_mp_callback_configured
- 识别平台订单/pending标记/openid↔device 绑定留后续

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:44:21 +08:00
marco 446c69cf9b fix(withdraw): OPPO channel_id 改用管理台登记的 push_oplus_category_content
上一提交(0921cb8)误用占位值 shagua_wallet, 真机会不展示; 改为用户在 OPPO 管理台建的通信 ID。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:37:11 +08:00
marco 0921cb84d9 feat(withdraw): OPPO 推送带 channel_id + category 预留
- unicast notification 带 channel_id(默认 push_oplus_category_content, OPPO 系统内容渠道)
- category 做成 config 可配(默认空); 升级 service 渠道+ACCOUNT 分类只改配置不改代码
- 客户端无需改动(OPPO 系统预置渠道 App 不自建)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:32:28 +08:00
marco 5ae4c474e4 feat(withdraw): OPPO 厂商通道直连推送 — 提现审核通过通知
- integrations/oppo_push.py: OPPO PUSH 服务端直连(sha256 鉴权 + auth_token 24h 缓存 + unicast 单推)
- device_liveness 加 oppo_register_id 列 + 迁移; register/heartbeat 透传 + list 查询
- services/push_notify: 审核通过 fire-and-forget 推送(best-effort, 不阻塞审核响应)
- admin withdraw approve(单笔+批量)挂钩(status=failed 已退款不推)
- 单测 10 passed; 真调 OPPO 生产 auth/unicast 验证签名与请求构造

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:16:51 +08:00
25 changed files with 878 additions and 465 deletions
+7
View File
@@ -32,6 +32,13 @@ 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,59 +0,0 @@
"""device table(设备档案 / 终端注册)
Revision ID: bb47051068c8
Revises: comparison_llm_cost
Create Date: 2026-07-16 14:22:17.770307
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'bb47051068c8'
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:
op.create_table(
'device',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_id', sa.String(length=128), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('platform', sa.String(length=16), nullable=False),
sa.Column('oem', sa.String(length=32), nullable=True),
sa.Column('model', sa.String(length=64), nullable=True),
sa.Column('os_version', sa.String(length=32), nullable=True),
sa.Column('app_version', sa.String(length=32), nullable=True),
sa.Column('channel', sa.String(length=32), nullable=True),
sa.Column('screen', sa.String(length=32), nullable=True),
sa.Column('network', sa.String(length=16), nullable=True),
sa.Column('timezone', sa.String(length=64), nullable=True),
sa.Column('is_emulator', sa.Boolean(), nullable=True),
sa.Column('latitude', sa.Float(), nullable=True),
sa.Column('longitude', sa.Float(), nullable=True),
sa.Column('last_ip', sa.String(length=64), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('last_active_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('device', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_device_device_id'), ['device_id'], unique=True)
batch_op.create_index(batch_op.f('ix_device_last_active_at'), ['last_active_at'], unique=False)
batch_op.create_index(batch_op.f('ix_device_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('device', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_device_user_id'))
batch_op.drop_index(batch_op.f('ix_device_last_active_at'))
batch_op.drop_index(batch_op.f('ix_device_device_id'))
op.drop_table('device')
@@ -0,0 +1,34 @@
"""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')
+1 -1
View File
@@ -20,7 +20,7 @@ from app.models.admin import AdminAuditLog
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.device_liveness import DeviceLiveness
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
+7
View File
@@ -37,6 +37,7 @@ 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",
@@ -315,6 +316,9 @@ 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(
@@ -438,6 +442,9 @@ 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)
+2
View File
@@ -40,6 +40,7 @@ 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,
)
@@ -64,6 +65,7 @@ def report_heartbeat(
device_id=req.device_id,
accessibility_enabled=req.accessibility_enabled,
registration_id=req.registration_id,
oppo_register_id=req.oppo_register_id,
)
return OkResponse()
-51
View File
@@ -1,51 +0,0 @@
"""设备档案上报 endpoint(device 表)。
POST /api/v1/device/report 客户端上报设备当前信息, device_id upsert device
软鉴权:带合法 Bearer 设备关联到该用户;游客态( token)也接受,user_id 暂空
注意与 /api/v1/device/register(app/api/v1/device.py)区分:那个是无障碍存活注册,
device_liveness 硬鉴权;本端点是设备信息档案, device 软鉴权
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
from app.api.deps import DbSession, OptionalUser
from app.repositories import device_profile as device_profile_repo
from app.schemas.device_profile import DeviceReportOut, DeviceReportRequest
logger = logging.getLogger("shagua.device_profile")
router = APIRouter(prefix="/api/v1/device", tags=["device"])
def _client_ip(request: Request) -> str | None:
"""客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP。"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else None
@router.post("/report", response_model=DeviceReportOut, summary="上报设备信息(设备档案 upsert)")
def report_device(
req: DeviceReportRequest,
request: Request,
user: OptionalUser,
db: DbSession,
) -> DeviceReportOut:
device = device_profile_repo.upsert_device(
db,
req,
user_id=user.id if user else None,
last_ip=_client_ip(request),
)
logger.info(
"device report device_id=%s user_id=%s has_loc=%s",
req.device_id,
device.user_id,
req.latitude is not None,
)
return DeviceReportOut()
+106
View File
@@ -0,0 +1,106 @@
"""微信服务号消息接收回调 /wx/mp/callback(裸路径, 非 /api/v1)。
对应公众平台"设置与开发 → 基本配置 → 服务器配置"里填的 URL
GET : URL 接入验证 校验 signature 原样返回 echostr(明文)
POST : 收用户消息(安全模式密文) msg_signature 验签 AES 解密 解析 XML
图片消息(MsgType=image): 打日志(openid/PicUrl/MediaId) + PicUrl 下载落盘
其余消息/事件: 记一条日志忽略一律返回 "success"(微信据此不再重试)
MVP 只做"收到图片并落盘"证明链路通;后续接:识别平台+订单 pending_compare 标记
心跳带回前端弹窗,以及 openiddevice 绑定任何异常都返回 "success"不让微信重试轰炸
"""
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from pathlib import Path
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import PlainTextResponse
from app.core.config import settings
from app.integrations import wx_mp_crypto
logger = logging.getLogger("shagua.wx_mp")
router = APIRouter(prefix="/wx/mp", tags=["wx-mp"])
# 收到的截图暂存目录(MVP 验证用;接上识别后可改为不落盘或定期清理)
_INBOX = Path(settings.MEDIA_ROOT) / "wx_inbox"
@router.get("/callback", include_in_schema=False)
async def verify(
signature: str = "", timestamp: str = "", nonce: str = "", echostr: str = ""
) -> PlainTextResponse:
"""微信 URL 接入验证:校验 signature 通过则原样返回 echostr。"""
if not settings.WX_MP_TOKEN:
logger.warning("wx_mp verify: WX_MP_TOKEN 未配置")
return PlainTextResponse("", status_code=503)
if wx_mp_crypto.verify_url_signature(
settings.WX_MP_TOKEN, timestamp, nonce, signature
):
return PlainTextResponse(echostr)
logger.warning("wx_mp verify: signature 校验失败 ts=%s nonce=%s", timestamp, nonce)
return PlainTextResponse("invalid signature", status_code=403)
@router.post("/callback", include_in_schema=False)
async def receive(request: Request) -> PlainTextResponse:
"""收用户消息(安全模式)。任何异常都吞掉返回 success,不让微信重试轰炸,靠日志排查。"""
if not settings.wx_mp_callback_configured:
logger.warning("wx_mp receive: 回调凭证未配齐(Token/AESKey/AppID)")
return PlainTextResponse("success")
try:
body = (await request.body()).decode("utf-8")
qp = request.query_params
# 安全模式外层 XML 只有 ToUserName + Encrypt。来源=微信服务器(HTTPS)+ 下面验签,
# 且 stdlib ET 不扩展外部实体, XXE 不适用。
encrypt = ET.fromstring(body).findtext("Encrypt") or ""
if not wx_mp_crypto.verify_msg_signature(
settings.WX_MP_TOKEN,
qp.get("timestamp", ""),
qp.get("nonce", ""),
encrypt,
qp.get("msg_signature", ""),
):
logger.warning("wx_mp receive: msg_signature 校验失败")
return PlainTextResponse("success")
xml = wx_mp_crypto.decrypt_message(
settings.WX_MP_AES_KEY, settings.WX_MP_APPID, encrypt
)
await _handle_message(ET.fromstring(xml))
except Exception:
logger.exception("wx_mp receive: 处理异常")
return PlainTextResponse("success")
async def _handle_message(root: ET.Element) -> None:
openid = root.findtext("FromUserName") or ""
msg_type = root.findtext("MsgType") or ""
if msg_type == "image":
pic_url = root.findtext("PicUrl") or ""
media_id = root.findtext("MediaId") or ""
logger.info(
"wx_mp 收到图片: openid=%s media_id=%s pic_url=%s", openid, media_id, pic_url
)
await _download(pic_url, openid, media_id)
else:
logger.info("wx_mp 收到消息(暂忽略): openid=%s type=%s", openid, msg_type)
async def _download(pic_url: str, openid: str, media_id: str) -> None:
"""用 PicUrl 直接下载图片落盘。PicUrl 是临时公网链接,无需 access_token / IP 白名单。"""
if not pic_url:
return
try:
_INBOX.mkdir(parents=True, exist_ok=True)
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(pic_url)
resp.raise_for_status()
dest = _INBOX / f"{openid[:12]}_{media_id[:16]}.jpg"
dest.write_bytes(resp.content)
logger.info("wx_mp 图片已落盘: %s (%d bytes)", dest, len(resp.content))
except Exception:
logger.exception("wx_mp 图片下载失败 pic_url=%s", pic_url)
+39
View File
@@ -71,6 +71,34 @@ 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
@@ -136,6 +164,12 @@ class Settings(BaseSettings):
# (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。
WX_MP_OAUTH_ENABLED: bool = False
# ===== 微信服务号(消息接收回调) =====
# 用户发消息给服务号 → 微信 POST 到 /wx/mp/callback(安全模式:Token 验签 + AESKey 解密)。
# 区别于上面的网页授权(那是落地页拿 openid);这里是被动收用户主动发来的消息(截图比价入口)。
WX_MP_TOKEN: str = "" # 公众平台"服务器配置"的 Token(URL 验签 + 消息验签)
WX_MP_AES_KEY: str = "" # EncodingAESKey(43 位, 消息 AES-256-CBC 加解密)
@property
def wx_mp_configured(self) -> bool:
"""服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。"""
@@ -146,6 +180,11 @@ class Settings(BaseSettings):
"""落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。"""
return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED
@property
def wx_mp_callback_configured(self) -> bool:
"""消息接收回调凭证齐全(Token + AESKey + AppID)。缺则回调端点拒绝处理消息。"""
return bool(self.WX_MP_TOKEN and self.WX_MP_AES_KEY and self.WX_MP_APPID)
# ===== 微信支付(商家转账到零钱 / 提现)=====
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
+183
View File
@@ -0,0 +1,183 @@
"""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 重试后仍失败")
+69
View File
@@ -0,0 +1,69 @@
"""微信服务号消息接收回调的验签与解密(安全模式)。
服务号"服务器配置"选安全模式后:
- URL 接入验证(GET): sha1(sort(token, timestamp, nonce)) == signature 原样返回 echostr
- 消息(POST): body <Encrypt> 是密文;
msg_signature = sha1(sort(token, timestamp, nonce, encrypt))
密文 AES-256-CBC 解出: random(16B) + msg_len(4B big-endian) + msg + appid, PKCS7(=32) padding
只做验签 + 解密(收消息)被动回复(加密)MVP 不需要 收到后走客服消息异步回执
密钥/口令来自 settings(WX_MP_TOKEN / WX_MP_AES_KEY / WX_MP_APPID),本模块只做纯算法不读配置
"""
from __future__ import annotations
import base64
import hashlib
import hmac
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def verify_url_signature(token: str, timestamp: str, nonce: str, signature: str) -> bool:
"""GET 接入验证:token/timestamp/nonce 三者字典序排序拼接后 sha1。"""
return _consteq(_sha1(token, timestamp, nonce), signature)
def verify_msg_signature(
token: str, timestamp: str, nonce: str, encrypt: str, msg_signature: str
) -> bool:
"""POST 消息验签:四者(含密文 encrypt)字典序排序拼接后 sha1。"""
return _consteq(_sha1(token, timestamp, nonce, encrypt), msg_signature)
def decrypt_message(aes_key_b64: str, expected_appid: str, encrypt_b64: str) -> str:
"""解密 <Encrypt> 密文, 返回明文消息 XML。appid 不符抛 ValueError。
aes_key_b64: EncodingAESKey(43 , 不含结尾 '='), '=' base64 解出 32 字节 AES-256 key
"""
aes_key = base64.b64decode(aes_key_b64 + "=") # 43 → 32 bytes
iv = aes_key[:16]
decryptor = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).decryptor()
plain = decryptor.update(base64.b64decode(encrypt_b64)) + decryptor.finalize()
plain = _pkcs7_unpad(plain)
# random(16) + msg_len(4, big-endian) + msg(msg_len) + from_appid
content = plain[16:]
msg_len = int.from_bytes(content[:4], "big")
msg = content[4 : 4 + msg_len]
from_appid = content[4 + msg_len :].decode("utf-8")
if expected_appid and from_appid != expected_appid:
raise ValueError(f"appid mismatch: {from_appid!r} != {expected_appid!r}")
return msg.decode("utf-8")
def _sha1(*parts: str) -> str:
return hashlib.sha1("".join(sorted(parts)).encode("utf-8")).hexdigest()
def _consteq(a: str, b: str) -> bool:
return hmac.compare_digest(a, b)
def _pkcs7_unpad(data: bytes) -> bytes:
"""微信用块大小 32 的 PKCS7,末字节即 padding 长度(1..32)。越界则原样返回(容错)。"""
if not data:
return data
pad = data[-1]
if pad < 1 or pad > 32:
return data
return data[:-pad]
+3 -2
View File
@@ -28,7 +28,6 @@ from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.v1.device import router as device_router
from app.api.v1.device_profile import router as device_profile_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.invite import router as invite_router
from app.api.v1.meituan import router as meituan_router
@@ -40,6 +39,7 @@ from app.api.v1.signin import router as signin_router
from app.api.v1.tasks import router as tasks_router
from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wx_mp import router as wx_mp_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.daily_exchange_worker import (
@@ -121,7 +121,6 @@ app.include_router(analytics_router)
app.include_router(invite_router)
app.include_router(coupon_router)
app.include_router(device_router)
app.include_router(device_profile_router)
app.include_router(compare_router)
app.include_router(compare_record_router)
app.include_router(compare_milestone_router)
@@ -142,6 +141,8 @@ app.include_router(internal_launch_confirm_router)
app.include_router(platform_router)
# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团)
app.include_router(cps_redirect_router)
# 微信服务号消息接收回调 /wx/mp/callback(公网无鉴权:微信服务器验签, 截图比价入口)
app.include_router(wx_mp_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
+1 -2
View File
@@ -19,8 +19,7 @@ from app.models.cps_link import CpsClick, CpsLink # noqa: F401
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.cps_wx_user import CpsWxUser # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.device import Device # noqa: F401
from app.models.device_liveness import DeviceLiveness # noqa: F401
from app.models.device import DeviceLiveness # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
+69 -51
View File
@@ -1,71 +1,86 @@
"""设备表(设备档案 / 终端注册)。
"""设备表(无障碍保护存活检测 + 极光推送)。
每条 = 台设备,以客户端生成的 device_id 唯一标识(格式 device_<MODEL>_<8hex>,
per-install; pricebot 客户端 PriceBotService.getOrCreateDeviceId)登录前(游客态)
即可建档,登录后回填 user_id 单表只记最近一个登录用户,不保留一机多号历史
每条 = 个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
registration_id(极光推送目标)后端 heartbeat_monitor_worker 扫描曾经保护过
现在心跳超时的设备,通过极光推送提醒用户重开无障碍
字段采集来源(逐列见注释):
已在埋点 analytics_event 采集 : oem / os_version / model / app_version / channel / network
服务端补 : last_ip(X-Forwarded-For)last_active_at
客户端需新增上报(无需权限) : timezoneis_emulator
客户端需新增上报 + 定位权限 + 隐私合规(PIPL 敏感信息): latitude / longitude
device_liveness(无障碍存活/极光推送, device_liveness.py)是两张相互独立的表,
靠同一个 device_id 关联,不要合并
liveness_state 状态机(防刷屏,一次掉线只推一条):
unknown alive(收到 service 心跳) silent/notified(扫描发现超时并已推送)
心跳恢复时 handler 重置回 alive
spec: spec/accessibility-liveness-push.md
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Device(Base):
__tablename__ = "device"
class DeviceLiveness(Base):
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
__tablename__ = "device_liveness"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 客户端生成的设备唯一 ID(全局唯一);与 analytics_event / device_liveness 用同一个值
device_id: Mapped[str] = mapped_column(
String(128), unique=True, index=True, nullable=False
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 关联用户:登录后回填「最近一次登录」的 user;游客态为空(可空 → 不阻塞未登录建档)
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=True
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
registration_id: 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)
# 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)
ever_protected: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# ---- 设备 / 系统信息(埋点已采集,注册/上报接口带过来即可) ----
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android") # android/ios/harmony
oem: Mapped[str | None] = mapped_column(String(32), nullable=True) # 厂商 Build.MANUFACTURER:xiaomi/huawei…
model: Mapped[str | None] = mapped_column(String(64), nullable=True) # 型号 Build.MODEL,如 PJF110
os_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 系统版本,如 "Android 13"
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # app 版本
channel: Mapped[str | None] = mapped_column(String(32), nullable=True) # 安装渠道(应用市场)
screen: Mapped[str | None] = mapped_column(String(32), nullable=True) # 分辨率 "1080x2400"
network: Mapped[str | None] = mapped_column(String(16), nullable=True) # 最近网络类型 wifi/4g/5g
# ---- 需客户端「新增」上报的字段 ----
# 设备时区 TimeZone.getDefault().id,如 "Asia/Shanghai"(客户端需新增上报,无需权限)
timezone: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 是否模拟器(客户端 Build 指纹判断后上报;NULL=未知,无需权限)
is_emulator: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# 位置(经纬度):App 会申请定位权限,但可能拿不到(首次运行未授权 / 用户拒绝 / 关了 GPS)。
# 规则(leader 定):每次上报以实际为准——能拿到就写,拿不到就置 NULL 清空旧值。
# ⚠️ upsert 时 lat/lng 必须「整字段覆盖(含 None)」,不能像 model/oem 那样 COALESCE 保留旧值,
# 否则会留下一条早已离开该位置的陈旧坐标。
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
# ---- 上下文 / 状态(服务端维护) ----
last_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) # 最近一次上报 IP(服务端从 X-Forwarded-For 取)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="normal") # normal/banned(风控封设备)
# 最近活跃时间(设备维度 DAU / 留存统计用;updated_at 只在字段真变化时跳,故单列一个)
last_active_at: Mapped[datetime | None] = mapped_column(
# 首次开无障碍(首次收到 accessibility_enabled 心跳)的时刻;ever_protected 第一次翻 true 时记一次,
# 后续心跳不覆盖。老设备(迁移前已 protected)无此值 → NULL。
first_protected_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 最近一次 service 心跳时间(存活证明);超时即视为保护掉线
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
)
# 最近一次上报的无障碍开关状态(观测用)
last_report_protection_on: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# unknown / alive / silent / notified
liveness_state: Mapped[str] = mapped_column(
String(16), nullable=False, default="unknown"
)
# 最近一次推送告警时间
notified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 掉线告警「待客户端提醒」标记(后置检测 pull 版, 见 spec accessibility-liveness-pull-prompt.md)。
# 与 liveness_state 解耦: worker 检出掉线即置 True; touch_heartbeat(心跳恢复)不动它
# → 规避「服务随 App 重启先发心跳、state 被重置回 alive → 客户端进 App 漏看」竞态; 只由客户端 ack 清。
kill_alert_pending: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
@@ -78,4 +93,7 @@ class Device(Base):
)
def __repr__(self) -> str: # pragma: no cover
return f"<Device id={self.id} device_id={self.device_id} user_id={self.user_id}>"
return (
f"<DeviceLiveness id={self.id} user_id={self.user_id} "
f"device_id={self.device_id} state={self.liveness_state}>"
)
-96
View File
@@ -1,96 +0,0 @@
"""设备表(无障碍保护存活检测 + 极光推送)。
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
registration_id(极光推送目标)后端 heartbeat_monitor_worker 扫描曾经保护过
现在心跳超时的设备,通过极光推送提醒用户重开无障碍
liveness_state 状态机(防刷屏,一次掉线只推一条):
unknown alive(收到 service 心跳) silent/notified(扫描发现超时并已推送)
心跳恢复时 handler 重置回 alive
spec: spec/accessibility-liveness-push.md
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class DeviceLiveness(Base):
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
__tablename__ = "device_liveness"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)
ever_protected: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# 首次开无障碍(首次收到 accessibility_enabled 心跳)的时刻;ever_protected 第一次翻 true 时记一次,
# 后续心跳不覆盖。老设备(迁移前已 protected)无此值 → NULL。
first_protected_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 最近一次 service 心跳时间(存活证明);超时即视为保护掉线
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
)
# 最近一次上报的无障碍开关状态(观测用)
last_report_protection_on: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# unknown / alive / silent / notified
liveness_state: Mapped[str] = mapped_column(
String(16), nullable=False, default="unknown"
)
# 最近一次推送告警时间
notified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 掉线告警「待客户端提醒」标记(后置检测 pull 版, 见 spec accessibility-liveness-pull-prompt.md)。
# 与 liveness_state 解耦: worker 检出掉线即置 True; touch_heartbeat(心跳恢复)不动它
# → 规避「服务随 App 重启先发心跳、state 被重置回 alive → 客户端进 App 漏看」竞态; 只由客户端 ack 清。
kill_alert_pending: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<DeviceLiveness id={self.id} user_id={self.user_id} "
f"device_id={self.device_id} state={self.liveness_state}>"
)
+30 -3
View File
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.device_liveness import DeviceLiveness
from app.models.device import DeviceLiveness
def _get(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None:
@@ -22,16 +22,18 @@ 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 / 元信息。upsert by (user_id, device_id)。"""
"""注册设备或更新其 registration_id / oppo_register_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,
)
@@ -39,6 +41,8 @@ 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:
@@ -55,11 +59,13 @@ 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(掉线恢复 下次再断才会再推一条)
清掉 notified_at(掉线恢复 下次再断才会再推一条)registration_id / oppo_register_id
非空时顺带更新(客户端拿到 push token 后每次心跳带上, 后端最终一致)
"""
now = datetime.now(timezone.utc)
device = _get(db, user_id=user_id, device_id=device_id)
@@ -69,6 +75,8 @@ 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:
@@ -118,6 +126,25 @@ 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)
-66
View File
@@ -1,66 +0,0 @@
"""device 表(设备档案)读写:按 device_id upsert。
repositories/device.py(无障碍存活 DeviceLiveness)是不同的表:本文件写 device
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.device import Device
from app.schemas.device_profile import DeviceReportRequest
# 设备/系统信息:提供了(非 None)才覆盖,某次上报漏带不会把已有值冲掉
_STICKY_STR_FIELDS = ("oem", "model", "os_version", "app_version", "channel", "screen", "network", "timezone")
def upsert_device(
db: Session,
req: DeviceReportRequest,
*,
user_id: int | None,
last_ip: str | None,
) -> Device:
"""按 device_id upsert 一台设备的档案。
写入策略分三类:
- 设备/系统信息(_STICKY_STR_FIELDS + platform + is_emulator):非空才覆盖(sticky)
- 位置 latitude/longitude:**整字段覆盖, None**以本次上报实际为准,拿不到即清空
(leader 规则, model Device.latitude 注释;不能像上面那样 sticky)
- user_id:仅登录态(user_id None)回填为当前登录用户;游客态保留已有关联,不清空
last_active_at / last_ip 每次刷新
"""
now = datetime.now(timezone.utc)
device = db.execute(
select(Device).where(Device.device_id == req.device_id)
).scalar_one_or_none()
if device is None:
device = Device(device_id=req.device_id)
db.add(device)
# 设备/系统信息:非空才覆盖
if req.platform:
device.platform = req.platform
for field in _STICKY_STR_FIELDS:
val = getattr(req, field)
if val is not None:
setattr(device, field, val)
if req.is_emulator is not None:
device.is_emulator = req.is_emulator
# 位置:整字段覆盖(含 None),以本次实际为准
device.latitude = req.latitude
device.longitude = req.longitude
# 关联用户:仅登录态回填,游客态不动
if user_id is not None:
device.user_id = user_id
device.last_ip = last_ip
device.last_active_at = now
db.commit()
db.refresh(device)
return device
+3
View File
@@ -9,6 +9,7 @@ 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
@@ -18,6 +19,7 @@ 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):
@@ -26,6 +28,7 @@ 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
-35
View File
@@ -1,35 +0,0 @@
"""设备档案上报 schema(device 表)。
schemas/device.py(无障碍存活 DeviceLiveness register/heartbeat)是不同用途:
本文件对应 device (设备信息/档案),schemas/device.py 对应 device_liveness
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class DeviceReportRequest(BaseModel):
"""客户端上报的一台设备的当前信息(每次以实际为准)。"""
device_id: str = Field(max_length=128)
platform: str = Field(default="android", max_length=16)
# 设备 / 系统信息:提供了才覆盖(见 repo:非空 sticky)
oem: str | None = Field(default=None, max_length=32)
model: str | None = Field(default=None, max_length=64)
os_version: str | None = Field(default=None, max_length=32)
app_version: str | None = Field(default=None, max_length=32)
channel: str | None = Field(default=None, max_length=32)
screen: str | None = Field(default=None, max_length=32)
network: str | None = Field(default=None, max_length=16)
timezone: str | None = Field(default=None, max_length=64)
is_emulator: bool | None = None
# 位置:客户端每次带「本次实际」的经纬度,拿不到就传 null(或不传)→ 服务端清空。
# 见 model Device.latitude 注释:这两个字段整字段覆盖,不做 sticky。
latitude: float | None = None
longitude: float | None = None
class DeviceReportOut(BaseModel):
ok: bool = True
+74
View File
@@ -0,0 +1,74 @@
"""推送通知编排(查设备 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)
+1 -1
View File
@@ -1,6 +1,6 @@
# device_liveness — 无障碍存活监控(心跳 + 掉线召回)
> 模型 `app/models/device_liveness.py`(`DeviceLiveness`) · 仓库 `app/repositories/device.py`(`register_or_update` / `touch_heartbeat` / `list_overdue` / `mark_notified` / `get_device` / `ack_kill_alert`) · 接口 用户 `POST /api/v1/device/register``POST /api/v1/device/heartbeat``GET /api/v1/device/liveness``POST /api/v1/device/liveness/ack`(`app/api/v1/device.py`);后台 worker `heartbeat_monitor_worker` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/device.py`(`DeviceLiveness`) · 仓库 `app/repositories/device.py`(`register_or_update` / `touch_heartbeat` / `list_overdue` / `mark_notified` / `get_device` / `ack_kill_alert`) · 接口 用户 `POST /api/v1/device/register``POST /api/v1/device/heartbeat``GET /api/v1/device/liveness``POST /api/v1/device/liveness/ack`(`app/api/v1/device.py`);后台 worker `heartbeat_monitor_worker` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每行 = 一个用户的一台设备(per-install,`(user_id, device_id)` 唯一)。客户端无障碍服务存活时周期上报心跳刷新 `last_heartbeat_at`;App 前台/登录拿到极光 push token 时上报 `registration_id`。后端 `heartbeat_monitor_worker` 扫「曾保护过、现已心跳超时」的设备,推送(或本期仅终端打印)提醒用户重开无障碍。**表名不叫 `device`**:它存的不是设备信息(品牌/型号),而是**无障碍存活状态**。#65 新增。
+78
View File
@@ -476,3 +476,81 @@ 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 == []
-98
View File
@@ -1,98 +0,0 @@
"""设备档案上报端点 /api/v1/device/report + upsert 测试。"""
from __future__ import annotations
from sqlalchemy import select
from app.core.security import create_token
from app.db.session import SessionLocal
from app.models.device import Device
from app.models.user import User
def _payload(**over) -> dict:
base = {
"device_id": "dev-report-1",
"platform": "android",
"oem": "Xiaomi",
"model": "PJF110",
"os_version": "Android 14",
"app_version": "0.2.12(62)",
"channel": "yingyongbao",
"screen": "1080x2400",
"network": "wifi",
"timezone": "Asia/Shanghai",
"is_emulator": False,
"latitude": 31.23,
"longitude": 121.47,
}
base.update(over)
return base
def _get(device_id: str) -> Device | None:
with SessionLocal() as db:
return db.execute(
select(Device).where(Device.device_id == device_id)
).scalar_one_or_none()
def test_report_creates_device_as_guest(client) -> None:
r = client.post("/api/v1/device/report", json=_payload(device_id="dev-guest"))
assert r.status_code == 200, r.text
assert r.json()["ok"] is True
d = _get("dev-guest")
assert d is not None
assert d.user_id is None # 游客态:未绑用户
assert d.oem == "Xiaomi"
assert d.is_emulator is False
assert d.latitude == 31.23
assert d.last_active_at is not None
def test_report_binds_user_when_authed(client) -> None:
with SessionLocal() as db:
u = User(phone="13800000001", username="20000000001")
db.add(u)
db.commit()
uid = u.id
token, _ = create_token(user_id=uid, token_type="access")
r = client.post(
"/api/v1/device/report",
json=_payload(device_id="dev-user"),
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200, r.text
assert _get("dev-user").user_id == uid
def test_report_clears_location_when_absent(client) -> None:
# 首次带位置
client.post(
"/api/v1/device/report",
json=_payload(device_id="dev-loc", latitude=10.0, longitude=20.0),
)
assert _get("dev-loc").latitude == 10.0
# 再次上报拿不到位置(lat/lng=None)→ 必须清空,不能保留旧坐标
r = client.post(
"/api/v1/device/report",
json=_payload(device_id="dev-loc", latitude=None, longitude=None, oem="HONOR"),
)
assert r.status_code == 200, r.text
d = _get("dev-loc")
assert d.latitude is None # 位置以实际为准 → 清空
assert d.longitude is None
assert d.oem == "HONOR" # 设备信息正常更新
def test_report_sticky_fields_not_wiped_by_missing(client) -> None:
# 首次全量
client.post("/api/v1/device/report", json=_payload(device_id="dev-sticky"))
# 再次只带 device_id + 位置(不带 oem/model)→ 设备信息保留旧值(非空才覆盖)
client.post(
"/api/v1/device/report",
json={"device_id": "dev-sticky", "latitude": 1.0, "longitude": 2.0},
)
d = _get("dev-sticky")
assert d.oem == "Xiaomi" # 未被漏带的 None 冲掉
assert d.model == "PJF110"
assert d.latitude == 1.0 # 位置按本次
+101
View File
@@ -0,0 +1,101 @@
"""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")
+70
View File
@@ -0,0 +1,70 @@
"""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()