Compare commits

..

1 Commits

Author SHA1 Message Date
unknown afc55ab910 feat(device): 新增设备信息表 device + 上报接口
- 新建 device 设备表(设备/系统信息 + 网络/时区/模拟器/位置 + 关联用户 + 状态/时间戳),
  以客户端 device_id 唯一标识;登录后回填 user_id,游客态可空
- 原 device 模型改名 device_liveness(实为无障碍存活表),腾出 device 名给新表并保持
  表名与实体名一致;同步更新 __init__ / repositories / admin queries / 文档引用
- 新增 POST /api/v1/device/report:软鉴权(带 JWT 绑用户、游客也收),按 device_id upsert;
  设备信息非空才覆盖,位置 latitude/longitude 整字段覆盖含清空(以本次上报实际为准)
- 新增 alembic migration bb47051068c8 建 device 表(裁掉 autogenerate 带出的无关表漂移)
- 补 /device/report 端点与 upsert 单元测试 4 例
2026-07-17 09:46:59 +08:00
20 changed files with 465 additions and 635 deletions
@@ -0,0 +1,59 @@
"""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')
+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 import DeviceLiveness
from app.models.device_liveness import DeviceLiveness
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
+3 -18
View File
@@ -14,24 +14,18 @@ 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"])
@@ -58,12 +52,12 @@ def register_device(
return DeviceOut.model_validate(device)
@router.post("/heartbeat", response_model=HeartbeatResponse, summary="上报心跳")
@router.post("/heartbeat", response_model=OkResponse, summary="上报心跳")
def report_heartbeat(
req: HeartbeatRequest,
user: CurrentUser,
db: DbSession,
) -> HeartbeatResponse:
) -> OkResponse:
device_repo.touch_heartbeat(
db,
user_id=user.id,
@@ -71,16 +65,7 @@ def report_heartbeat(
accessibility_enabled=req.accessibility_enabled,
registration_id=req.registration_id,
)
# 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)
return OkResponse()
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
+51
View File
@@ -0,0 +1,51 @@
"""设备档案上报 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()
-159
View File
@@ -1,159 +0,0 @@
"""微信客服(企业微信)消息接收回调 /wx/kf/callback(裸路径, 非 /api/v1)。
对应企业微信微信客服 客服账号 接收消息回调里填的 URL是服务号版(wx_mp.py)
平行实现:下游(set_pending 心跳下发 前端弹窗)完全一致, 只有上游"如何拿到用户消息"不同
服务号是微信把消息直接 POST 给你;微信客服是 POST 一个事件通知, 你再用 <Token> 主动 sync_msg
GET : URL 接入验证 msg_signature 验签(含密文 echostr) 解密 echostr 返回明文
与公众号明文模式不同:企业微信 echostr 是密文, 必须解密后返回
POST : 收事件(密文) msg_signature 验签 AES 解密 解析 XML
Event=kf_msg_or_event <Token>/<OpenKfId> sync_msg 增量拉消息
客户(origin=3)发来的图片(image) set_pending(测试设备, "meituan") + 下载落盘
其余消息/事件记日志忽略一律返回 "success"(异常也吞掉, 不让企业微信重试轰炸)
回调加密与公众号安全模式同源, 直接复用 integrations/wx_mp_crypto(验签 + AES-256-CBC 解密),
差别只在 receiveid corpid(公众号是 appid)MVP 只做"收图打信号 + 落盘证明链路通"
"""
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
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_kf_client, wx_mp_crypto
logger = logging.getLogger("shagua.wx_kf")
router = APIRouter(prefix="/wx/kf", tags=["wx-kf"])
# 收到的截图暂存目录(MVP 验证用;接上识别后可改为不落盘或定期清理)
_INBOX = Path(settings.MEDIA_ROOT) / "wx_kf_inbox"
@router.get("/callback", include_in_schema=False)
async def verify(
msg_signature: str = "", timestamp: str = "", nonce: str = "", echostr: str = ""
) -> PlainTextResponse:
"""企业微信 URL 接入验证:验签(密文 echostr 参与)→ 解密 echostr → 返回明文。"""
if not settings.wx_kf_callback_configured:
logger.warning("wx_kf verify: 回调凭证未配齐(CorpId/Token/AESKey/Secret)")
return PlainTextResponse("", status_code=503)
if not wx_mp_crypto.verify_msg_signature(
settings.WX_KF_TOKEN, timestamp, nonce, echostr, msg_signature
):
logger.warning("wx_kf verify: signature 校验失败 ts=%s nonce=%s", timestamp, nonce)
return PlainTextResponse("invalid signature", status_code=403)
try:
plain = wx_mp_crypto.decrypt_message(
settings.WX_KF_AES_KEY, settings.WX_KF_CORP_ID, echostr
)
except Exception:
logger.exception("wx_kf verify: echostr 解密失败")
return PlainTextResponse("decrypt failed", status_code=403)
return PlainTextResponse(plain)
@router.post("/callback", include_in_schema=False)
async def receive(request: Request) -> PlainTextResponse:
"""收事件通知(安全模式)。任何异常都吞掉返回 success, 不让企业微信重试轰炸, 靠日志排查。"""
if not settings.wx_kf_callback_configured:
logger.warning("wx_kf receive: 回调凭证未配齐")
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_KF_TOKEN,
qp.get("timestamp", ""),
qp.get("nonce", ""),
encrypt,
qp.get("msg_signature", ""),
):
logger.warning("wx_kf receive: msg_signature 校验失败")
return PlainTextResponse("success")
xml = wx_mp_crypto.decrypt_message(
settings.WX_KF_AES_KEY, settings.WX_KF_CORP_ID, encrypt
)
await _handle_event(ET.fromstring(xml))
except Exception:
logger.exception("wx_kf receive: 处理异常")
return PlainTextResponse("success")
async def _handle_event(root: ET.Element) -> None:
"""客服事件:MsgType=event 且 Event=kf_msg_or_event → 用 Token 增量拉消息。"""
event = root.findtext("Event") or ""
if event != "kf_msg_or_event":
logger.info("wx_kf 收到事件(暂忽略): event=%s", event)
return
token = root.findtext("Token") or ""
open_kfid = root.findtext("OpenKfId") or ""
msgs = await wx_kf_client.sync_messages(token, open_kfid)
logger.info("wx_kf sync_msg 拉到 %d 条 open_kfid=%s", len(msgs), open_kfid)
for msg in msgs:
await _handle_message(msg)
async def _handle_message(msg: dict[str, Any]) -> None:
"""处理单条客服消息。只关心【客户发来的】(origin=3)图片 / 小程序卡片:打比价信号(+ 落盘)。
origin:3=客户发的 / 4=系统推送 / 5=接待人员发的接待人员或系统消息一律不触发比价
两种触发物对应场景:image=用户发结算页截图;miniprogram=用户分享美团小程序卡片(见需求截图)
"""
if msg.get("origin") != 3:
return
msgtype = msg.get("msgtype") or ""
external_userid = msg.get("external_userid") or ""
if msgtype == "image":
media_id = (msg.get("image") or {}).get("media_id", "")
logger.info(
"wx_kf 收到图片: external_userid=%s media_id=%s", external_userid, media_id
)
_fire_pending("image")
await _save_media(media_id, external_userid)
elif msgtype == "miniprogram":
mp = msg.get("miniprogram") or {}
logger.info(
"wx_kf 收到小程序卡片: external_userid=%s title=%s appid=%s",
external_userid,
mp.get("title"),
mp.get("appid"),
)
# 卡片无可下载媒体, 只打信号;源平台/订单识别(可据 appid 判平台)留后续, 现写死美团。
_fire_pending("miniprogram")
else:
logger.info(
"wx_kf 收到消息(暂忽略): external_userid=%s type=%s", external_userid, msgtype
)
def _fire_pending(kind: str) -> None:
"""PoC:给写死的测试设备打"从美团比价"信号(下次心跳带回前端弹窗, 与服务号版完全一致)。"""
poc_dev = settings.WX_POC_TEST_DEVICE_ID
if not poc_dev:
return
wx_poc_signal.set_pending(poc_dev, "meituan")
logger.info("wx_kf PoC: 已给测试设备 %s 打比价信号 source=meituan (触发=%s)", poc_dev, kind)
async def _save_media(media_id: str, external_userid: str) -> None:
"""图片消息只给 media_id, 走临时素材下载接口拉回落盘(PoC 验证用)。"""
content = await wx_kf_client.download_media(media_id)
if not content:
return
try:
_INBOX.mkdir(parents=True, exist_ok=True)
dest = _INBOX / f"{external_userid[:16]}_{media_id[:16]}.jpg"
dest.write_bytes(content)
logger.info("wx_kf 图片已落盘: %s (%d bytes)", dest, len(content))
except Exception:
logger.exception("wx_kf 图片落盘失败 media_id=%s", media_id)
-112
View File
@@ -1,112 +0,0 @@
"""微信服务号消息接收回调 /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 import wx_poc_signal
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
)
# 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)
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)
-35
View File
@@ -136,26 +136,6 @@ 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 加解密)
# ===== 微信截图比价 PoC(临时验证链路, 验证后删) =====
# 只对这台写死的测试设备下发"从美团比价"信号;空=不触发任何设备(部署上线零风险)。
# 收图 → 给此 device_id 打 pending → 该设备下次心跳带回 → 前端弹选平台窗。
WX_POC_TEST_DEVICE_ID: str = ""
# ===== 微信客服(企业微信;截图比价上游入口 —— 服务号版的平行实现) =====
# 用户在「微信客服」会话发消息 → 企业微信 POST 事件到 /wx/kf/callback → 后端 sync_msg 拉消息。
# 回调加密与公众号「安全模式」同源(复用 wx_mp_crypto), 但 receiveid 用 corpid 且 echostr 需解密。
# 拉消息 / 下载图片走 qyapi, 需 access_token(corpid + secret 换取)。全空=回调端点拒收, 零风险。
WX_KF_CORP_ID: str = "" # 企业微信 corpid(回调解密 receiveid + 换 access_token)
WX_KF_SECRET: str = "" # 「微信客服」Secret(换 access_token)
WX_KF_TOKEN: str = "" # 客服「接收消息」回调的 Token(URL 验签 + 消息验签)
WX_KF_AES_KEY: str = "" # 客服「接收消息」回调的 EncodingAESKey(AES-256-CBC 解密)
@property
def wx_mp_configured(self) -> bool:
"""服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。"""
@@ -166,21 +146,6 @@ 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)
@property
def wx_kf_callback_configured(self) -> bool:
"""微信客服回调凭证齐全(CorpId + Token + AESKey + Secret)。缺则回调端点拒绝处理。"""
return bool(
self.WX_KF_CORP_ID
and self.WX_KF_TOKEN
and self.WX_KF_AES_KEY
and self.WX_KF_SECRET
)
# ===== 微信支付(商家转账到零钱 / 提现)=====
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
-27
View File
@@ -1,27 +0,0 @@
"""微信截图比价 PoC:进程内存的"待比价"信号(单 worker 够用, 重启即失效, PoC 可接受)。
收图端点 set_pending(device_id, source) 该设备下次心跳 pop_pending 取走并清除
心跳响应带回 前端弹选平台窗只对 settings.WX_POC_TEST_DEVICE_ID 写入, 不碰其他设备
后续接真识别 / openiddevice 绑定时整体替换本模块
"""
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)
-126
View File
@@ -1,126 +0,0 @@
"""微信客服(企业微信)API 客户端:access_token 缓存 + sync_msg 拉消息 + 临时素材下载。
对应 api/v1/wx_kf.py:收到 `kf_msg_or_event` 回调后, 用这里的函数增量拉取用户消息
- access_token(7200s)进程内缓存, 提前 300s 刷新( worker 够用, 重启即失效)
- sync_msg 游标 next_cursor open_kfid 进程内存续(PoC 重启后从最近 3 天重拉, 可接受)
凭证来自 settings(WX_KF_CORP_ID / WX_KF_SECRET);纯外部 HTTP, 不含业务逻辑
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger("shagua.wx_kf")
_QYAPI = "https://qyapi.weixin.qq.com/cgi-bin"
# access_token 进程内缓存(单 worker)。token 空 / 未到刷新点直接用。
_token_lock = asyncio.Lock()
_token_cache: dict[str, Any] = {"token": "", "expire_at": 0.0}
# sync_msg 游标:open_kfid -> next_cursor(PoC 进程内存;重启从最近 3 天重拉)
_cursors: dict[str, str] = {}
async def _get_access_token() -> str:
"""取企业微信 access_token, 进程内缓存, 提前 300s 过期刷新。失败返回空串(调用方降级)。"""
now = time.time()
if _token_cache["token"] and _token_cache["expire_at"] - 300 > now:
return _token_cache["token"]
async with _token_lock:
now = time.time() # 拿锁后复检, 避免并发重复刷新
if _token_cache["token"] and _token_cache["expire_at"] - 300 > now:
return _token_cache["token"]
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{_QYAPI}/gettoken",
params={
"corpid": settings.WX_KF_CORP_ID,
"corpsecret": settings.WX_KF_SECRET,
},
)
data = resp.json()
except Exception:
logger.exception("wx_kf gettoken 请求失败")
return ""
if data.get("errcode"):
logger.warning(
"wx_kf gettoken errcode=%s errmsg=%s", data.get("errcode"), data.get("errmsg")
)
return ""
_token_cache["token"] = data.get("access_token", "")
_token_cache["expire_at"] = now + int(data.get("expires_in", 7200))
return _token_cache["token"]
async def sync_messages(token: str, open_kfid: str) -> list[dict[str, Any]]:
"""收到 kf_msg_or_event 回调后调用:用回调带的一次性 token 增量拉消息, has_more 循环拉完。
token: 回调事件里的 <Token>(消息拉取凭证, 非配置 Token;不传会有严格频控)
open_kfid:客服账号 id(回调里的 <OpenKfId>)
返回本次新拉到的 msg_list(已合并多页);游标按 open_kfid 进程内存续
"""
access_token = await _get_access_token()
if not access_token:
return []
out: list[dict[str, Any]] = []
cursor = _cursors.get(open_kfid, "")
try:
async with httpx.AsyncClient(timeout=15) as client:
for _ in range(20): # 最多 20 页护栏, 防异常时死循环
body: dict[str, Any] = {"cursor": cursor, "token": token, "limit": 1000}
if open_kfid:
body["open_kfid"] = open_kfid
resp = await client.post(
f"{_QYAPI}/kf/sync_msg",
params={"access_token": access_token},
json=body,
)
data = resp.json()
if data.get("errcode"):
logger.warning(
"wx_kf sync_msg errcode=%s errmsg=%s",
data.get("errcode"),
data.get("errmsg"),
)
break
out.extend(data.get("msg_list", []))
cursor = data.get("next_cursor", cursor)
_cursors[open_kfid] = cursor
if not data.get("has_more"):
break
except Exception:
logger.exception("wx_kf sync_msg 请求失败 open_kfid=%s", open_kfid)
return out
async def download_media(media_id: str) -> bytes | None:
"""临时素材下载(图片消息只给 media_id)。成功返回二进制, 失败返回 None。"""
if not media_id:
return None
access_token = await _get_access_token()
if not access_token:
return None
try:
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(
f"{_QYAPI}/media/get",
params={"access_token": access_token, "media_id": media_id},
)
resp.raise_for_status()
# 成功=二进制文件流;失败=JSON(errcode)。据 Content-Type 区分。
ctype = resp.headers.get("Content-Type", "")
if "application/json" in ctype or "text/plain" in ctype:
logger.warning("wx_kf media/get 非文件响应: %s", resp.text[:200])
return None
return resp.content
except Exception:
logger.exception("wx_kf media/get 下载失败 media_id=%s", media_id)
return None
-69
View File
@@ -1,69 +0,0 @@
"""微信服务号消息接收回调的验签与解密(安全模式)。
服务号"服务器配置"选安全模式后:
- 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]
+2 -6
View File
@@ -28,6 +28,7 @@ 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
@@ -39,8 +40,6 @@ 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_kf import router as wx_kf_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 (
@@ -122,6 +121,7 @@ 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,10 +142,6 @@ 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)
# 微信客服(企业微信)消息接收回调 /wx/kf/callback(服务号版的平行实现, 收事件→sync_msg 拉图)
app.include_router(wx_kf_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
+2 -1
View File
@@ -19,7 +19,8 @@ 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 DeviceLiveness # noqa: F401
from app.models.device import Device # noqa: F401
from app.models.device_liveness import DeviceLiveness # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
+50 -65
View File
@@ -1,83 +1,71 @@
"""设备表(无障碍保护存活检测 + 极光推送)。
"""设备表(设备档案 / 终端注册)。
每条 = 个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
registration_id(极光推送目标)后端 heartbeat_monitor_worker 扫描曾经保护过
现在心跳超时的设备,通过极光推送提醒用户重开无障碍
每条 = 台设备,以客户端生成的 device_id 唯一标识(格式 device_<MODEL>_<8hex>,
per-install; pricebot 客户端 PriceBotService.getOrCreateDeviceId)登录前(游客态)
即可建档,登录后回填 user_id 单表只记最近一个登录用户,不保留一机多号历史
liveness_state 状态机(防刷屏,一次掉线只推一条):
unknown alive(收到 service 心跳) silent/notified(扫描发现超时并已推送)
心跳恢复时 handler 重置回 alive
spec: spec/accessibility-liveness-push.md
字段采集来源(逐列见注释):
已在埋点 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 关联,不要合并
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, 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"),
)
class Device(Base):
__tablename__ = "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
# 客户端生成的设备唯一 ID(全局唯一);与 analytics_event / device_liveness 用同一个值
device_id: Mapped[str] = mapped_column(
String(128), unique=True, index=True, nullable=False
)
# 首次开无障碍(首次收到 accessibility_enabled 心跳)的时刻;ever_protected 第一次翻 true 时记一次,
# 后续心跳不覆盖。老设备(迁移前已 protected)无此值 → NULL。
first_protected_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
# 关联用户:登录后回填「最近一次登录」的 user;游客态为空(可空 → 不阻塞未登录建档)
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=True
)
# 最近一次 service 心跳时间(存活证明);超时即视为保护掉线
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
# ---- 设备 / 系统信息(埋点已采集,注册/上报接口带过来即可) ----
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(
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
@@ -90,7 +78,4 @@ class DeviceLiveness(Base):
)
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}>"
)
return f"<Device id={self.id} device_id={self.device_id} user_id={self.user_id}>"
+96
View File
@@ -0,0 +1,96 @@
"""设备表(无障碍保护存活检测 + 极光推送)。
每条 = 一个用户的一台设备(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}>"
)
+1 -1
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 import DeviceLiveness
from app.models.device_liveness import DeviceLiveness
def _get(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None:
+66
View File
@@ -0,0 +1,66 @@
"""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
-14
View File
@@ -36,20 +36,6 @@ 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(无告警)"""
+35
View File
@@ -0,0 +1,35 @@
"""设备档案上报 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
+1 -1
View File
@@ -1,6 +1,6 @@
# device_liveness — 无障碍存活监控(心跳 + 掉线召回)
> 模型 `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)
> 模型 `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)
每行 = 一个用户的一台设备(per-install,`(user_id, device_id)` 唯一)。客户端无障碍服务存活时周期上报心跳刷新 `last_heartbeat_at`;App 前台/登录拿到极光 push token 时上报 `registration_id`。后端 `heartbeat_monitor_worker` 扫「曾保护过、现已心跳超时」的设备,推送(或本期仅终端打印)提醒用户重开无障碍。**表名不叫 `device`**:它存的不是设备信息(品牌/型号),而是**无障碍存活状态**。#65 新增。
+98
View File
@@ -0,0 +1,98 @@
"""设备档案上报端点 /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 # 位置按本次