基于 main 接入各厂商直推服务端 (#118)

改动:新增厂商推送配置、设备 push_vendor/push_token 字段、device push-test 接口、心跳超时厂商直推发送逻辑和对应测试。

验证:python -m pytest tests/test_device_push.py tests/test_auth.py tests/test_health.py 通过。

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: 左辰勇 <exinglang@gmail.com>
Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #118
Co-authored-by: Ghost <>
Co-committed-by: Ghost <>
This commit was merged in pull request #118.
This commit is contained in:
Ghost
2026-07-22 15:42:25 +08:00
committed by guke
parent 2eb36b44c8
commit 0717c09721
47 changed files with 5497 additions and 32 deletions
+141
View File
@@ -0,0 +1,141 @@
"""直接触发「消息通知中心」真实推送链路,给指定用户(默认 11111111111)的已注册设备发 push。
用于**后台无法驱动**的事件联调(本环境:wxpay 未配 → 提现成功打不通、提现单唯一约束 →
造不了多张待审单、好友下单后台无入口)。本脚本直接调 services/notification_events 的真实
下发函数,走的就是生产同一条链路:落 notification 表(站内消息) + 厂商直推(honor/huawei/
xiaomi/oppo/vivo)到该用户 device_liveness 里已注册的 push token。
默认只发这 3 类(后台驱动不了的):
#3 withdraw_success 提现到账
#4 withdraw_failed 提现失败,款项已退回
#12 invite_order_reward 好友下单奖励到账
可用 --types 指定;--types all 追加后台能驱动的 #9/#10/#11(注意:这几类的点击跳转 id 是假的,
仅验证「推送到达手机」,真实跳转请走后台审核流程)。
.venv\\Scripts\\python.exe scripts\\fire_push_events.py # 3 类各 10 条
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --count 1 # 各 1 条(先小量验证通道)
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --types withdraw_failed --count 3
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --types all --count 2
推送成败看输出里的 `shagua.vendor_push` 日志(push sent / push failed);2 台设备则每条各推 2 次。
凭据缺失或 token 失效时 notification_events 只记日志、不抛错(站内消息仍会落库)。
"""
from __future__ import annotations
import argparse
import logging
import random
import sys
import uuid
from app.core.rewards import INVITE_COMPARE_REWARD_CENTS, PRICE_REPORT_REWARD_COINS
from app.db.session import SessionLocal, engine
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.wallet import WithdrawOrder
from app.repositories import device as device_repo
from app.repositories import user as user_repo
from app.services import notification_events
# SQL 回显静音;shagua.* 开到 INFO,好看到「push sent / push failed」结果
# dev 下 APP_DEBUG=true → engine echo=True,echo 走 InstanceLogger 直写、无视 logger level,只能关 echo 本身
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
engine.echo = False
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
DEFAULT_PHONE = "11111111111"
DEFAULT_TYPES = ["withdraw_success", "withdraw_failed", "invite_order_reward"]
ADMIN_DRIVEN = ["feedback_reward", "feedback_reply", "report_approved"] # --types all 追加
ALL_TYPES = DEFAULT_TYPES + ADMIN_DRIVEN
_FAIL_REASONS = [
"微信零钱未实名,款项已退回",
"收款账户异常,款项已退回",
"超出微信零钱收款限额,款项已退回",
]
def _fire_one(db, uid: int, type_key: str, i: int) -> None:
"""构造一条该类型的瞬态业务对象(不落业务表,只为给 notify 函数读字段),触发真实推送。"""
if type_key == "withdraw_success":
order = WithdrawOrder(user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=50, source="coin_cash")
notification_events.notify_withdraw_success(db, order)
elif type_key == "withdraw_failed":
order = WithdrawOrder(
user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=350, source="coin_cash",
fail_reason=random.choice(_FAIL_REASONS),
)
notification_events.notify_withdraw_failed(db, order)
elif type_key == "invite_order_reward":
# 假被邀请人 id(> 真实用户范围,避重):昵称回退「好友」。真实昵称请走 API 流程(见文末说明)。
fake_invitee = random.randint(900000, 999999)
notification_events.notify_invite_order_reward(
db, inviter_user_id=uid, invitee_user_id=fake_invitee, cash_cents=INVITE_COMPARE_REWARD_CENTS
)
elif type_key == "feedback_reward":
fb = Feedback(user_id=uid, content="(直发)", contact="", status="adopted",
reward_coins=300, admin_reply="感谢反馈,您说的问题已修复上线,金币请查收~")
fb.id = random.randint(900000, 999999)
notification_events.notify_feedback_reward(db, fb)
elif type_key == "feedback_reply":
fb = Feedback(user_id=uid, content="(直发)", contact="", status="rejected",
admin_reply="您的建议我们记录啦,会在后续版本评估~")
fb.id = random.randint(900000, 999999)
notification_events.notify_feedback_reply(db, fb)
elif type_key == "report_approved":
rep = PriceReport(
user_id=uid, reported_platform_id="jd", reported_platform_name="京东外卖",
reported_price_cents=8800, images=[], status="approved",
reward_coins=PRICE_REPORT_REWARD_COINS, store_name=f"测试火锅店{i:02d}",
)
rep.id = random.randint(900000, 999999)
notification_events.notify_report_approved(db, rep)
else:
raise SystemExit(f"未知类型: {type_key}(可选: {', '.join(ALL_TYPES)})")
def main() -> None:
parser = argparse.ArgumentParser(description="直接触发消息通知中心真实推送(后台驱动不了的事件用)")
parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})")
parser.add_argument("--count", type=int, default=10, help="每类发多少条(默认 10)")
parser.add_argument(
"--types", default=",".join(DEFAULT_TYPES),
help=f"逗号分隔的类型;'all' = {', '.join(ALL_TYPES)}。默认 {', '.join(DEFAULT_TYPES)}",
)
args = parser.parse_args()
types = ALL_TYPES if args.types.strip() == "all" else [t.strip() for t in args.types.split(",") if t.strip()]
bad = [t for t in types if t not in ALL_TYPES]
if bad:
print(f"❌ 未知类型: {', '.join(bad)}(可选: {', '.join(ALL_TYPES)})")
return
db = SessionLocal()
try:
user = user_repo.get_user_by_phone(db, args.phone)
if user is None:
print(f"❌ 用户 {args.phone} 不存在。请先用该手机号在 App 登录一次再跑本脚本。")
return
uid = user.id
targets = device_repo.list_push_targets(db, user_id=uid)
print(f"目标用户 {args.phone}(id={uid});已注册推送设备 {len(targets)} 台:"
f"{[t.push_vendor for t in targets] or '无(手机收不到!先在 App 上报 push token)'}")
print(f"即将触发:{types},每类 {args.count} 条 → 共 {len(types) * args.count}\n")
for t in types:
print(f"── {t} ×{args.count} " + "" * 30)
for i in range(1, args.count + 1):
_fire_one(db, uid, t, i)
print(f"\n✅ 已触发完。站内消息已落 notification 表(用 {args.phone} 登录 App 可在消息中心看到);"
"\n 手机推送成败见上方 `shagua.vendor_push` 日志(push sent=成功 / push failed=失败)。")
finally:
db.close()
if __name__ == "__main__":
main()