基于 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:
@@ -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()
|
||||
@@ -0,0 +1,420 @@
|
||||
"""给指定用户(默认手机号 11111111111)造一整套「消息通知中心」联调数据。
|
||||
|
||||
不只是 notification 本身,还把 13 种类型**点击后要跳转的落地页数据**一起造齐,保证每条都能点开看到真实内容:
|
||||
|
||||
notification 类型 点击落地 需要的业务数据(本脚本一并造)
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
reward_expiring/expired 赚钱页(tab) —(金额在通知里,无需外部记录)
|
||||
withdraw_success 无跳转,仅消红点 —
|
||||
withdraw_failed 提现页(withdrawId) withdraw_order(failed 一单)
|
||||
perm_*(4 种) 客户端权限检测弹窗 —(纯客户端)
|
||||
feedback_reply 我的反馈(feedbackId) feedback(rejected + 官方回复)
|
||||
feedback_reward 我的反馈(feedbackId) feedback(adopted + 官方留言 + 奖励金币)
|
||||
report_approved 我的爆料(reportId) price_report(approved + 截图 + 奖励)
|
||||
invite_order_reward 邀请页 invite_relation + 好友 user(已完成比价)
|
||||
invite_remind 邀请页(scrollTo) invite_relation + 好友 user(未完成)
|
||||
|
||||
配套还造:钱包余额 + 金币/现金/邀请奖励金流水(让赚钱页 / 金币明细 / 现金明细 / 邀请战绩都有内容)。
|
||||
|
||||
幂等:每次先清掉该用户上一轮由本脚本造的全部数据(通知 + 上述业务记录 + mock 好友 + mock 截图)再重建。
|
||||
.venv\\Scripts\\python.exe scripts\\seed_mock_notifications.py
|
||||
.venv\\Scripts\\python.exe scripts\\seed_mock_notifications.py --phone 11111111111
|
||||
.venv\\Scripts\\python.exe scripts\\seed_mock_notifications.py --clean-only
|
||||
|
||||
时间口径按各域现有约定:notification.sent_at 用东八区(带 +08:00 下发);feedback / withdraw /
|
||||
钱包流水 / 邀请关系用 naive UTC(= func.now() 在 SQLite 的口径,与真实数据一致);price_report 用
|
||||
naive 北京时间(与 report_repo.create_report 一致)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
import uuid
|
||||
import zlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.invite import InviteRelation
|
||||
from app.models.notification import Notification
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
InviteCashTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
from app.repositories import notification as notif_repo
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8") # Windows GBK 控制台也能打印中文/¥
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
DEFAULT_PHONE = "11111111111"
|
||||
|
||||
# mock 好友(被邀请人):固定手机号,便于幂等清理。(phone, 昵称, 是否已完成比价)
|
||||
FRIEND_SPECS = [
|
||||
("12000000001", "柚子", True), # 已完成 → 驱动 invite_order_reward,计入邀请战绩
|
||||
("12000000003", "小美", True), # 已完成 → 让邀请列表 / 战绩更丰满
|
||||
("12000000002", "阿泽", False), # 未完成 → 驱动 invite_remind(去催单)
|
||||
]
|
||||
FRIEND_PHONES = [p for p, _, _ in FRIEND_SPECS]
|
||||
|
||||
_REPORT_DIR = Path(settings.MEDIA_ROOT) / "price_report"
|
||||
_MOCK_IMG_GLOB = "mock_notif_*.png" # 本脚本生成的截图前缀,清理按此删
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 时间口径小工具
|
||||
# ---------------------------------------------------------------------------
|
||||
def _utc() -> datetime:
|
||||
"""naive UTC now(与 func.now() 在 SQLite 一致:feedback / withdraw / 流水 / 邀请关系用)。"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _bj_naive() -> datetime:
|
||||
"""naive 北京 wall-clock(price_report 用,与 report_repo.create_report 一致)。"""
|
||||
return datetime.now(_CST).replace(tzinfo=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mock 截图(纯色 PNG,无需 Pillow;抄 seed_mock_price_reports 的手写字节法)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _solid_png(width: int, height: int, rgb: tuple[int, int, int]) -> bytes:
|
||||
def _chunk(typ: bytes, data: bytes) -> bytes:
|
||||
body = typ + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # RGB truecolor
|
||||
row = b"\x00" + bytes(rgb) * width
|
||||
idat = zlib.compress(row * height, 9)
|
||||
return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def _write_mock_image(name: str, rgb: tuple[int, int, int]) -> str:
|
||||
_REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(_REPORT_DIR / name).write_bytes(_solid_png(320, 320, rgb))
|
||||
return f"{settings.MEDIA_URL_PREFIX}/price_report/{name}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 清理(幂等)
|
||||
# ---------------------------------------------------------------------------
|
||||
def clean(db, target: User) -> None:
|
||||
uid = target.id
|
||||
friend_ids = list(
|
||||
db.execute(select(User.id).where(User.phone.in_(FRIEND_PHONES))).scalars()
|
||||
)
|
||||
|
||||
# 1) 目标用户的通知 + 业务记录 + 钱包
|
||||
for model in (
|
||||
Notification, Feedback, PriceReport, WithdrawOrder,
|
||||
CashTransaction, CoinTransaction, InviteCashTransaction, CoinAccount,
|
||||
):
|
||||
db.execute(delete(model).where(model.user_id == uid))
|
||||
# 2) 邀请关系(目标作为邀请人 + mock 好友作为被邀请人)
|
||||
db.execute(delete(InviteRelation).where(InviteRelation.inviter_user_id == uid))
|
||||
if friend_ids:
|
||||
db.execute(delete(InviteRelation).where(InviteRelation.invitee_user_id.in_(friend_ids)))
|
||||
db.execute(delete(User).where(User.id.in_(friend_ids)))
|
||||
db.commit()
|
||||
|
||||
# 3) mock 截图文件
|
||||
if _REPORT_DIR.exists():
|
||||
for f in _REPORT_DIR.glob(_MOCK_IMG_GLOB):
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 造业务记录(通知的点击落地数据)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _make_friends(db, inviter: User) -> dict[str, User]:
|
||||
"""建 mock 好友 user + 邀请关系(注册即生效;完成比价的置 compare_reward_granted 并发奖励金)。"""
|
||||
now = _utc()
|
||||
friends: dict[str, User] = {}
|
||||
for i, (phone, nickname, _completed) in enumerate(FRIEND_SPECS):
|
||||
u = User(
|
||||
phone=phone,
|
||||
username=user_repo._gen_unique_username(db),
|
||||
nickname=nickname,
|
||||
register_channel="sms",
|
||||
status="active",
|
||||
created_at=now - timedelta(days=6 - i),
|
||||
last_login_at=now - timedelta(hours=2),
|
||||
)
|
||||
db.add(u)
|
||||
friends[nickname] = u
|
||||
db.flush() # 拿 friend.id
|
||||
|
||||
for i, (_phone, nickname, completed) in enumerate(FRIEND_SPECS):
|
||||
f = friends[nickname]
|
||||
db.add(InviteRelation(
|
||||
inviter_user_id=inviter.id,
|
||||
invitee_user_id=f.id,
|
||||
channel="clipboard",
|
||||
status="effective",
|
||||
compare_reward_granted=completed,
|
||||
compare_reward_cents=rewards.INVITE_COMPARE_REWARD_CENTS if completed else 0,
|
||||
compare_rewarded_at=(now - timedelta(days=5 - i)) if completed else None,
|
||||
created_at=now - timedelta(days=6 - i),
|
||||
))
|
||||
return friends
|
||||
|
||||
|
||||
def _make_feedbacks(db, uid: int) -> dict[str, Feedback]:
|
||||
"""两条反馈:一条(rejected)带官方回复 → feedback_reply;一条(adopted)带留言+奖励 → feedback_reward。"""
|
||||
now = _utc()
|
||||
reply = Feedback(
|
||||
user_id=uid,
|
||||
content="比价结果页希望能一键复制到微信分享给朋友。",
|
||||
contact="",
|
||||
source="profile",
|
||||
status="rejected",
|
||||
admin_reply="您反馈的分享功能我们记录啦,会在后续版本评估上线,感谢支持~",
|
||||
review_note="需求已进池",
|
||||
reviewed_at=now - timedelta(hours=5),
|
||||
created_at=now - timedelta(days=1, hours=2),
|
||||
)
|
||||
reward = Feedback(
|
||||
user_id=uid,
|
||||
content="点某些店铺比价偶尔会闪退,机型 Redmi K60。",
|
||||
contact="",
|
||||
source="comparison",
|
||||
scene="compare_slow",
|
||||
status="adopted",
|
||||
admin_reply="感谢反馈,您说的闪退问题已修复上线,送您的金币请查收~",
|
||||
review_note="已修复:比价页空指针",
|
||||
reward_coins=300,
|
||||
reviewed_at=now - timedelta(days=1),
|
||||
created_at=now - timedelta(days=3),
|
||||
)
|
||||
db.add_all([reply, reward])
|
||||
db.flush()
|
||||
return {"reply": reply, "reward": reward}
|
||||
|
||||
|
||||
def _make_report(db, uid: int) -> PriceReport:
|
||||
"""一条 approved 上报(带真实可加载截图 + 奖励金币)→ report_approved 点击可看爆料详情。"""
|
||||
now = _bj_naive()
|
||||
img = _write_mock_image("mock_notif_report.png", (250, 173, 20))
|
||||
rep = PriceReport(
|
||||
user_id=uid,
|
||||
comparison_record_id=None,
|
||||
store_name="蜀大侠火锅(春熙路店)",
|
||||
dish_summary="招牌牛油锅 × 1、鲜毛肚 × 2",
|
||||
original_platform_id="meituan-waimai",
|
||||
original_platform_name="美团外卖",
|
||||
original_price_cents=13800,
|
||||
reported_platform_id="jd-waimai",
|
||||
reported_platform_name="京东外卖",
|
||||
reported_price_cents=11800,
|
||||
images=[img],
|
||||
status="approved",
|
||||
reward_coins=1000,
|
||||
reviewed_at=now - timedelta(days=39, hours=-1),
|
||||
created_at=now - timedelta(days=40),
|
||||
)
|
||||
db.add(rep)
|
||||
db.flush()
|
||||
return rep
|
||||
|
||||
|
||||
def _make_withdraws(db, uid: int) -> dict[str, WithdrawOrder]:
|
||||
"""两单提现:success(历史)+ failed(驱动 withdraw_failed 点击去提现页)。不造在审单,避活动单唯一约束。"""
|
||||
now = _utc()
|
||||
success = WithdrawOrder(
|
||||
user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=500, source="coin_cash",
|
||||
user_name="测试用户", status="success", wechat_state="SUCCESS",
|
||||
transfer_bill_no="1330" + str(uuid.uuid4().int)[:26],
|
||||
created_at=now - timedelta(days=5), updated_at=now - timedelta(days=5),
|
||||
)
|
||||
failed = WithdrawOrder(
|
||||
user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=350, source="coin_cash",
|
||||
user_name="测试用户", status="failed", wechat_state="FAIL",
|
||||
transfer_bill_no="1330" + str(uuid.uuid4().int)[:26],
|
||||
fail_reason="微信实名与提现实名不一致,款项已原路退回现金余额",
|
||||
created_at=now - timedelta(days=2), updated_at=now - timedelta(days=2) + timedelta(hours=1),
|
||||
)
|
||||
db.add_all([success, failed])
|
||||
db.flush()
|
||||
return {"success": success, "failed": failed}
|
||||
|
||||
|
||||
def _make_wallet(db, uid: int, friends: dict[str, User], withdraws: dict[str, WithdrawOrder]) -> None:
|
||||
"""钱包余额 + 三本流水(金币 / 现金 / 邀请奖励金),让赚钱页与各明细页都有内容。"""
|
||||
now = _utc()
|
||||
|
||||
# 金币流水(只增,链上 balance_after)
|
||||
coin_events = [
|
||||
(2000, "signin", (now - timedelta(days=6)).date().isoformat(), "每日签到"),
|
||||
(160, "reward_video", uuid.uuid4().hex, "看视频奖励"),
|
||||
(500, "task_enable_notification", "task_enable_notification", "开启消息提醒奖励"),
|
||||
(1000, "report_reward", None, "爆料审核通过奖励"),
|
||||
(300, "feedback_reward", None, "反馈采纳奖励"),
|
||||
]
|
||||
coin_bal = 0
|
||||
for amt, biz, ref, remark in coin_events:
|
||||
coin_bal += amt
|
||||
db.add(CoinTransaction(
|
||||
user_id=uid, amount=amt, balance_after=coin_bal, biz_type=biz,
|
||||
ref_id=ref, remark=remark, created_at=now - timedelta(days=4),
|
||||
))
|
||||
|
||||
# 现金流水:兑入 + 两单提现扣款 + 失败退款 → 期末 1500
|
||||
cash_events = [
|
||||
(now - timedelta(days=10), 2000, "exchange_in", None, "金币兑入"),
|
||||
(withdraws["success"].created_at, -500, "withdraw", withdraws["success"].out_bill_no, "提现扣款"),
|
||||
(withdraws["failed"].created_at, -350, "withdraw", withdraws["failed"].out_bill_no, "提现扣款"),
|
||||
(withdraws["failed"].updated_at, 350, "withdraw_refund", withdraws["failed"].out_bill_no, "提现退款"),
|
||||
]
|
||||
cash_events.sort(key=lambda e: e[0])
|
||||
cash_bal = 0
|
||||
for t, amt, biz, ref, remark in cash_events:
|
||||
cash_bal += amt
|
||||
db.add(CashTransaction(
|
||||
user_id=uid, amount_cents=amt, balance_after_cents=cash_bal,
|
||||
biz_type=biz, ref_id=ref, remark=remark, created_at=t,
|
||||
))
|
||||
|
||||
# 邀请奖励金流水:每个已完成好友发一笔 → 期末 = 已完成好友数 × 单笔奖励
|
||||
invite_bal = 0
|
||||
reward_cents = rewards.INVITE_COMPARE_REWARD_CENTS
|
||||
for i, (_phone, nickname, completed) in enumerate(FRIEND_SPECS):
|
||||
if not completed:
|
||||
continue
|
||||
invite_bal += reward_cents
|
||||
db.add(InviteCashTransaction(
|
||||
user_id=uid, amount_cents=reward_cents, balance_after_cents=invite_bal,
|
||||
biz_type="invite_reward", ref_id=str(friends[nickname].id),
|
||||
remark="好友比价奖励", created_at=now - timedelta(days=5 - i),
|
||||
))
|
||||
|
||||
total_earned = sum(a for a, *_ in coin_events)
|
||||
db.add(CoinAccount(
|
||||
user_id=uid,
|
||||
coin_balance=coin_bal,
|
||||
cash_balance_cents=cash_bal,
|
||||
invite_cash_balance_cents=invite_bal,
|
||||
total_coin_earned=total_earned,
|
||||
))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 造 13 类通知(extra 指向上面真实记录的 id)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _notif(uid: int, type_key: str, sent_at: datetime, *, read: bool = False,
|
||||
extra_override: dict | None = None, dedup_key: str | None = None) -> Notification:
|
||||
card = notif_repo.build_sample_card(type_key, sent_at=sent_at)
|
||||
extra = dict(card.get("extra", {}))
|
||||
if extra_override:
|
||||
extra.update(extra_override)
|
||||
return Notification(
|
||||
user_id=uid, type=type_key, is_read=read,
|
||||
read_at=(sent_at + timedelta(minutes=5)) if read else None,
|
||||
sent_at=sent_at, dedup_key=dedup_key,
|
||||
coins=card.get("coins"), cash_cents=card.get("cash_cents"),
|
||||
info_rows=card.get("info_rows", []), extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def _make_notifications(
|
||||
db, uid: int, fb: dict[str, Feedback], rep: PriceReport,
|
||||
wd: dict[str, WithdrawOrder], friends: dict[str, User],
|
||||
) -> list[Notification]:
|
||||
n = datetime.now(_CST)
|
||||
|
||||
def ago(**kw) -> datetime:
|
||||
return n - timedelta(**kw)
|
||||
|
||||
rows = [
|
||||
# —— 提现助手 ——(金额/现金卡;withdraw_failed 指向真实失败单)
|
||||
_notif(uid, "reward_expiring", ago(hours=2), dedup_key=f"batch_{n:%Y%m%d}"),
|
||||
_notif(uid, "reward_expired", ago(days=1, hours=3), read=True),
|
||||
_notif(uid, "withdraw_success", ago(minutes=10)),
|
||||
_notif(uid, "withdraw_success", ago(days=3), read=True), # 额外一条(历史,已读)
|
||||
_notif(uid, "withdraw_failed", ago(days=1, hours=1),
|
||||
extra_override={"withdrawId": str(wd["failed"].id)}),
|
||||
# —— 系统通知(权限异常 ×4;dedup_key=权限名,未读期间只保留一条)——
|
||||
_notif(uid, "perm_accessibility", ago(hours=1), dedup_key="accessibility"),
|
||||
_notif(uid, "perm_battery", ago(days=3), read=True, dedup_key="battery"),
|
||||
_notif(uid, "perm_autostart", ago(days=5), dedup_key="autostart"),
|
||||
_notif(uid, "perm_overlay", ago(days=6), read=True, dedup_key="overlay"),
|
||||
# —— 我的反馈(feedbackId 指向真实反馈)——
|
||||
_notif(uid, "feedback_reply", ago(hours=4),
|
||||
extra_override={"feedbackId": str(fb["reply"].id)}),
|
||||
_notif(uid, "feedback_reward", ago(days=1),
|
||||
extra_override={"feedbackId": str(fb["reward"].id)}),
|
||||
_notif(uid, "feedback_reply", ago(days=380), read=True, # 跨年(测「YYYY年M月D日」),已读
|
||||
extra_override={"feedbackId": str(fb["reply"].id)}),
|
||||
# —— 我的爆料(reportId 指向真实上报)——
|
||||
_notif(uid, "report_approved", ago(days=40), # 当年(测「M月D日」)
|
||||
extra_override={"reportId": str(rep.id)}),
|
||||
# —— 好友邀请(inviteeNickname 指向真实好友)——
|
||||
_notif(uid, "invite_order_reward", ago(minutes=20),
|
||||
extra_override={"inviteeNickname": "柚子"}),
|
||||
_notif(uid, "invite_remind", ago(days=2),
|
||||
extra_override={"inviteeNickname": "阿泽", "scrollTo": "remind"}),
|
||||
]
|
||||
db.add_all(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def seed(db, target: User) -> list[Notification]:
|
||||
uid = target.id
|
||||
friends = _make_friends(db, target)
|
||||
fb = _make_feedbacks(db, uid)
|
||||
rep = _make_report(db, uid)
|
||||
wd = _make_withdraws(db, uid)
|
||||
_make_wallet(db, uid, friends, wd)
|
||||
rows = _make_notifications(db, uid, fb, rep, wd, friends)
|
||||
db.commit()
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="给指定用户造消息通知中心 + 点击落地页 mock 数据")
|
||||
parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})")
|
||||
parser.add_argument("--clean-only", action="store_true", help="只清理,不重建")
|
||||
args = parser.parse_args()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
target = user_repo.get_user_by_phone(db, args.phone)
|
||||
if target is None:
|
||||
print(f"❌ 用户 {args.phone} 不存在。请先用该手机号在 App 登录一次(SMS mock:任意 6 位验证码)再跑本脚本。")
|
||||
return
|
||||
|
||||
clean(db, target)
|
||||
print(f"🧹 已清理用户 {args.phone}(id={target.id})上一轮 mock 通知 + 业务记录 + mock 好友/截图")
|
||||
if args.clean_only:
|
||||
print("✅ 仅清理,已完成。")
|
||||
return
|
||||
|
||||
rows = seed(db, target)
|
||||
unread = sum(1 for r in rows if not r.is_read)
|
||||
print(f"\n✅ 已为用户 {args.phone}(id={target.id})生成 {len(rows)} 条通知(未读 {unread}):")
|
||||
for r in sorted(rows, key=lambda x: x.sent_at, reverse=True):
|
||||
flag = " " if r.is_read else "●"
|
||||
print(f" {flag} {r.type:<20} {r.sent_at:%Y-%m-%d %H:%M} extra={r.extra}")
|
||||
print(
|
||||
"\n👉 用 11111111111 登录 App(SMS mock:任意 6 位验证码)看消息通知中心;"
|
||||
"\n 逐条点击验证跳转:反馈→我的反馈、爆料→我的爆料、提现失败→提现页、邀请→邀请页、权限→检测弹窗。"
|
||||
"\n 后端若没带 --reload,改了数据也无需重启(本脚本直接写库,接口实时读)。"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""给指定用户(默认 11111111111)造「后台可驱动」的推送联调数据。
|
||||
|
||||
覆盖能从**管理后台点一下就触发手机推送**的 3 类事件,每类 10 条待审记录:
|
||||
|
||||
事件(PRD #) 后台动作 造的数据
|
||||
────────────────────────────────────────────────────────────────────
|
||||
#10 反馈奖励 反馈工单 → 采纳(填回复留言 + 金币) 10 条 pending feedback(标「请采纳」)
|
||||
#9 官方回复 反馈工单 → 拒绝(填未采纳原因/留言) 10 条 pending feedback(标「请拒绝」)
|
||||
#11 爆料审核通过 上报更低价 → 通过 10 条 pending price_report
|
||||
|
||||
触发链路:admin 审核 → 发金币/改状态 → services/notification_events 落站内消息 + 厂商直推
|
||||
→ 该用户已注册设备(device_liveness)收到 push。
|
||||
|
||||
其余 3 类(#3 提现成功 / #4 提现失败 / #12 好友下单到账)后台无法在本环境驱动
|
||||
(wxpay 未配 / 提现单唯一约束 / 后台无入口),用 scripts/fire_push_events.py 直接触发。
|
||||
|
||||
幂等:每次先删掉本脚本上一轮造的记录(按内容标记 [PUSH测试] 识别,不动用户真实反馈/爆料),再重建。
|
||||
.venv\\Scripts\\python.exe scripts\\seed_push_admin_test.py
|
||||
.venv\\Scripts\\python.exe scripts\\seed_push_admin_test.py --phone 11111111111 --count 10
|
||||
.venv\\Scripts\\python.exe scripts\\seed_push_admin_test.py --clean-only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.price_report import PriceReport
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) # 静音 SQL 回显,输出更干净
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
DEFAULT_PHONE = "11111111111"
|
||||
MARK = "[PUSH测试]" # 本脚本造的数据统一带此标记,幂等清理按它识别(不误删真实数据)
|
||||
|
||||
|
||||
def clean(db, uid: int) -> tuple[int, int]:
|
||||
"""删掉本脚本上一轮造的带标记记录(任何状态都删,彻底重置)。返回 (删反馈数, 删爆料数)。"""
|
||||
fb_ids = list(db.execute(
|
||||
select(Feedback.id).where(Feedback.user_id == uid, Feedback.content.like(f"{MARK}%"))
|
||||
).scalars())
|
||||
rep_ids = list(db.execute(
|
||||
select(PriceReport.id).where(
|
||||
PriceReport.user_id == uid, PriceReport.store_name.like(f"{MARK}%")
|
||||
)
|
||||
).scalars())
|
||||
if fb_ids:
|
||||
db.execute(delete(Feedback).where(Feedback.id.in_(fb_ids)))
|
||||
if rep_ids:
|
||||
db.execute(delete(PriceReport).where(PriceReport.id.in_(rep_ids)))
|
||||
db.commit()
|
||||
return len(fb_ids), len(rep_ids)
|
||||
|
||||
|
||||
def seed(db, uid: int, count: int) -> None:
|
||||
# #10 反馈奖励:采纳这些 → 手机收「反馈奖励已到账」。采纳时记得在后台填「给用户的回复留言」
|
||||
# (PRD 要求发奖必带官方留言),否则通知里不带留言行。
|
||||
for i in range(1, count + 1):
|
||||
db.add(Feedback(
|
||||
user_id=uid,
|
||||
content=f"{MARK} 请【采纳】我 → 触发 #10 反馈奖励推送。测试反馈内容 {i:02d}:比价页能加个历史记录就好了。",
|
||||
contact="",
|
||||
source="profile",
|
||||
status="pending",
|
||||
))
|
||||
# #9 官方回复:拒绝这些 → 手机收「您的反馈有回复啦」。拒绝时填「未采纳原因」+「回复留言」。
|
||||
for i in range(1, count + 1):
|
||||
db.add(Feedback(
|
||||
user_id=uid,
|
||||
content=f"{MARK} 请【拒绝】我 → 触发 #9 官方回复推送。测试反馈内容 {i:02d}:希望支持某某小众平台比价。",
|
||||
contact="",
|
||||
source="comparison",
|
||||
scene="other",
|
||||
status="pending",
|
||||
))
|
||||
# #11 爆料审核通过:通过这些 → 手机收「爆料审核通过」(发固定金币)。
|
||||
for i in range(1, count + 1):
|
||||
db.add(PriceReport(
|
||||
user_id=uid,
|
||||
comparison_record_id=None,
|
||||
store_name=f"{MARK}测试火锅店{i:02d}",
|
||||
dish_summary="招牌套餐 × 1",
|
||||
original_platform_id="meituan-waimai",
|
||||
original_platform_name="美团外卖",
|
||||
original_price_cents=9900,
|
||||
reported_platform_id="jd-waimai",
|
||||
reported_platform_name="京东外卖",
|
||||
reported_price_cents=8800,
|
||||
images=[],
|
||||
status="pending",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="造后台可驱动的推送联调数据(反馈×2 + 爆料)")
|
||||
parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})")
|
||||
parser.add_argument("--count", type=int, default=10, help="每类造多少条(默认 10)")
|
||||
parser.add_argument("--clean-only", action="store_true", help="只清理本脚本造的数据,不重建")
|
||||
args = parser.parse_args()
|
||||
|
||||
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
|
||||
|
||||
nf, nr = clean(db, uid)
|
||||
print(f"🧹 已清理上一轮 [PUSH测试] 数据:反馈 {nf} 条、爆料 {nr} 条")
|
||||
if args.clean_only:
|
||||
print("✅ 仅清理,已完成。")
|
||||
return
|
||||
|
||||
seed(db, uid, args.count)
|
||||
print(f"\n✅ 已为 {args.phone}(id={uid})造好后台联调数据(每类 {args.count} 条):")
|
||||
print(f" • 反馈工单「请采纳」× {args.count} → 后台【采纳】(填回复留言+金币)→ 手机收 #10 反馈奖励")
|
||||
print(f" • 反馈工单「请拒绝」× {args.count} → 后台【拒绝】(填未采纳原因/留言)→ 手机收 #9 官方回复")
|
||||
print(f" • 上报更低价 × {args.count} → 后台【通过】→ 手机收 #11 爆料审核通过")
|
||||
print("\n👉 打开管理后台(:8771)对应列表即可看到这些待审记录,逐条审核就会推到手机。")
|
||||
print(" #3/#4/#12 本环境后台驱动不了,用:.venv\\Scripts\\python.exe scripts\\fire_push_events.py")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
REM Show all device_id + push regIds (push_token / registration_id) from the
|
||||
REM local SQLite DB. Double-click this file to see everything, or run from a
|
||||
REM console. Optional substring filter: show_device_regids.bat xiaomi
|
||||
REM Works from ANY directory (locates project root + venv python by itself).
|
||||
REM ASCII-only comments: cmd parses .bat in the console codepage (GBK); UTF-8
|
||||
REM Chinese here gets mangled into bogus commands.
|
||||
cd /d "%~dp0.."
|
||||
set "PY=python"
|
||||
if exist ".venv\Scripts\python.exe" set "PY=.venv\Scripts\python.exe"
|
||||
"%PY%" "scripts\show_device_regids.py" %*
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""导出 device_liveness 表里所有 device_id 及其对应的推送 regId,按 push_vendor 分组(本地开发工具)。
|
||||
|
||||
用法:
|
||||
双击 scripts/show_device_regids.bat,或命令行:
|
||||
python scripts/show_device_regids.py [filter]
|
||||
|
||||
[filter] 可选:大小写不敏感的子串,匹配 device_id / push_vendor / push_token /
|
||||
registration_id 任一列;不传则列出全部。
|
||||
show_device_regids.py xiaomi # 只看小米
|
||||
show_device_regids.py device_Pixel # 按 device_id 片段找
|
||||
|
||||
说明:
|
||||
- 直接以**只读**方式读 SQLite(server 在跑也不会抢写锁),所以开不开服务器都能用。
|
||||
- push_token = 各厂商的 regId/pushToken(新链路,当前在用);registration_id = 旧极光
|
||||
regId(历史兼容)。两列都打出来,方便跟 logcat 现役值逐字比对。
|
||||
- 输出刻意保持纯 ASCII 排版并竖排展示**完整值**:双击弹出的 cmd 走 GBK 码页,竖排纯
|
||||
ASCII 不会乱码;完整值不截断,才能直接复制去比对。
|
||||
- 若 DATABASE_URL 不是 sqlite(生产 postgres),这里只提示改用 psql。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _resolve_sqlite_path() -> Path | None:
|
||||
"""按 env > .env > 默认 的顺序拿 DATABASE_URL,解析出 sqlite 文件路径。"""
|
||||
url = os.environ.get("DATABASE_URL", "").strip()
|
||||
if not url:
|
||||
env = REPO_ROOT / ".env"
|
||||
if env.exists():
|
||||
for line in env.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("DATABASE_URL="):
|
||||
url = s.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
break
|
||||
if not url:
|
||||
url = "sqlite:///./data/app.db"
|
||||
if not url.startswith("sqlite"):
|
||||
print(f"[!] DATABASE_URL not sqlite: {url}")
|
||||
print(" This tool only reads a local SQLite dev DB. For prod use psql.")
|
||||
return None
|
||||
raw = url.split("///", 1)[1] if "///" in url else "./data/app.db"
|
||||
p = Path(raw)
|
||||
if not p.is_absolute():
|
||||
p = (REPO_ROOT / raw).resolve()
|
||||
return p
|
||||
|
||||
|
||||
def main() -> int:
|
||||
needle = sys.argv[1].lower() if len(sys.argv) > 1 else None
|
||||
|
||||
db = _resolve_sqlite_path()
|
||||
if db is None:
|
||||
return 2
|
||||
if not db.exists():
|
||||
print(f"[X] DB file not found: {db}")
|
||||
return 2
|
||||
|
||||
# 只读打开,避免和正在运行的 server 抢写锁。URI 路径用正斜杠(as_posix)规避
|
||||
# Windows 反斜杠/盘符在 file: URI 里的歧义;万一 URI 形式打不开再回退普通连接。
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True)
|
||||
except sqlite3.OperationalError:
|
||||
con = sqlite3.connect(str(db))
|
||||
con.row_factory = sqlite3.Row
|
||||
rows = con.execute(
|
||||
"""
|
||||
SELECT id, user_id, device_id, push_vendor, push_token, registration_id,
|
||||
platform, app_version, liveness_state, last_heartbeat_at, updated_at
|
||||
FROM device_liveness
|
||||
ORDER BY updated_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
con.close()
|
||||
|
||||
if needle:
|
||||
def hit(r: sqlite3.Row) -> bool:
|
||||
for k in ("device_id", "push_vendor", "push_token", "registration_id"):
|
||||
v = r[k]
|
||||
if v and needle in str(v).lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
rows = [r for r in rows if hit(r)]
|
||||
|
||||
with_token = sum(1 for r in rows if (r["push_token"] or "").strip())
|
||||
with_reg = sum(1 for r in rows if (r["registration_id"] or "").strip())
|
||||
|
||||
# 按 push_vendor 分组;None/空归入 "(none)"。组顺序:设备数多的在前,(none) 垫底;
|
||||
# 组内沿用 updated_at DESC(rows 查询时已如此排序,dict 保序即可)。
|
||||
groups: dict[str, list] = {}
|
||||
for r in rows:
|
||||
groups.setdefault(r["push_vendor"] or "(none)", []).append(r)
|
||||
ordered = sorted(
|
||||
groups.items(), key=lambda kv: (kv[0] == "(none)", -len(kv[1]), kv[0])
|
||||
)
|
||||
tally = " ".join(f"{v}={len(items)}" for v, items in ordered) or "-"
|
||||
|
||||
print(f"DB : {db}")
|
||||
line = f"device_liveness : {len(rows)} row(s)"
|
||||
if needle:
|
||||
line += f' filter="{sys.argv[1]}"'
|
||||
print(line)
|
||||
print(f"has push_token(vendor regId) : {with_token}"
|
||||
f" has registration_id(jiguang) : {with_reg}")
|
||||
print(f"vendors : {tally}")
|
||||
print("=" * 72)
|
||||
|
||||
if not rows:
|
||||
print("(no rows)")
|
||||
return 0
|
||||
|
||||
for vendor, items in ordered:
|
||||
print()
|
||||
print(f"===== vendor={vendor} : {len(items)} device(s) =====")
|
||||
for r in items:
|
||||
print(f" [#{r['id']}] user_id={r['user_id']} platform={r['platform']}"
|
||||
f" state={r['liveness_state']} app={r['app_version'] or '-'}")
|
||||
print(f" device_id : {r['device_id']}")
|
||||
print(f" push_token(regId): {r['push_token'] or '(empty)'}")
|
||||
print(f" registration_id : {r['registration_id'] or '(empty)'}")
|
||||
print(f" last_heartbeat : {r['last_heartbeat_at'] or '-'}"
|
||||
f" updated : {r['updated_at']}")
|
||||
print(" " + "-" * 68)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
REM Push test #12 invite_order_reward (1 notification per run, random amount).
|
||||
REM Works from ANY directory (locates project root + venv python by itself):
|
||||
REM scripts\test_push_invite_order_reward.bat
|
||||
REM Extra args pass through, e.g.: test_push_invite_order_reward.bat --invitee-phone 12000000001
|
||||
REM ASCII-only comments: cmd parses .bat in the console codepage (GBK), UTF-8
|
||||
REM Chinese here gets mangled into bogus commands.
|
||||
cd /d "%~dp0.."
|
||||
".venv\Scripts\python.exe" "scripts\test_push_invite_order_reward.py" %*
|
||||
@@ -0,0 +1,110 @@
|
||||
"""#12 好友下单到账(invite_order_reward)推送联调脚本 —— 每次执行只发 1 条,金额随机。
|
||||
|
||||
后台没有驱动这个事件的入口(真实链路要好友注册 + 完成首次比价);本脚本直接调
|
||||
services/notification_events.notify_invite_order_reward —— 与生产同一条下发链路:
|
||||
落 notification 表(站内消息)+ 向【邀请人】全部已注册厂商 token 直推(honor/huawei/xiaomi/oppo/vivo)。
|
||||
|
||||
可重复性:
|
||||
默认用随机假「被邀请人 id」做 dedup_key → 永不去重,想跑多少次都行(昵称兜底显示「好友」)。
|
||||
金额默认每次随机(0.01 ~ 99.99 元)→ 手机上按金额认出这条通知;--cents 可固定(线上真实值 200 = 2 元)。
|
||||
带 --invitee-phone 指定真实用户(如种子好友 12000000001 柚子)→ 通知里显示真实昵称;
|
||||
⚠️ 但 dedup_key = 被邀请人 id:上一条还未读时重复发会命中去重(日志出现 dedup hit,不落库不推送),
|
||||
在 App 里把那条读掉(或换号)即可再次触发。
|
||||
|
||||
用法(服务端进程无需在跑,脚本自己连库 + 直调厂商接口):
|
||||
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py # 随机金额发 1 条
|
||||
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --cents 200 # 固定 2.00 元
|
||||
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --invitee-phone 12000000001 # 真实昵称
|
||||
|
||||
结果判读(看输出日志):
|
||||
push sent = 厂商接口受理成功,手机应弹「好友下单奖励到账」通知
|
||||
push failed = 厂商拒绝(原因见日志:token 失效/凭据错误等)
|
||||
skip push = 该厂商凭据未配置,只落站内消息
|
||||
站内消息用 11111111111 登录 App → 消息中心「好友邀请」可见;点击应跳邀请页。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.models.notification import Notification
|
||||
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 / dedup hit
|
||||
# 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")
|
||||
|
||||
TYPE_KEY = "invite_order_reward"
|
||||
|
||||
|
||||
def _notif_count(db, uid: int) -> int:
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(Notification.user_id == uid, Notification.type == TYPE_KEY)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="#12 好友下单到账 推送联调(每次 1 条,金额默认随机)")
|
||||
parser.add_argument("--phone", default="11111111111", help="邀请人(收通知方)手机号,默认 11111111111")
|
||||
parser.add_argument("--cents", type=int, default=None,
|
||||
help="奖励金额,单位分(默认随机 1~9999;线上真实值 200)")
|
||||
parser.add_argument("--invitee-phone", default="",
|
||||
help="被邀请人手机号(可选):显示真实昵称;不传用随机假 id,昵称显示「好友」")
|
||||
args = parser.parse_args()
|
||||
|
||||
cents = args.cents if args.cents is not None else random.randint(1, 9999)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = user_repo.get_user_by_phone(db, args.phone)
|
||||
if user is None:
|
||||
print(f"❌ 用户 {args.phone} 不存在。先用该手机号在 App 登录一次再跑。")
|
||||
return
|
||||
|
||||
if args.invitee_phone.strip():
|
||||
invitee = user_repo.get_user_by_phone(db, args.invitee_phone.strip())
|
||||
if invitee is None:
|
||||
print(f"❌ 被邀请人 {args.invitee_phone} 不存在(种子好友可用 12000000001 柚子 / 12000000003 小美)。")
|
||||
return
|
||||
invitee_id = invitee.id
|
||||
else:
|
||||
invitee_id = random.randint(900000, 999999) # 假 id,昵称兜底「好友」,永不去重
|
||||
|
||||
targets = device_repo.list_push_targets(db, user_id=user.id)
|
||||
vendors = [t.push_vendor for t in targets]
|
||||
print(f"邀请人 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}")
|
||||
|
||||
before = _notif_count(db, user.id)
|
||||
print(f"→ 本次奖励 【{cents / 100:.2f} 元】(invitee_user_id={invitee_id}),手机上按金额认领这条通知")
|
||||
notification_events.notify_invite_order_reward(
|
||||
db, inviter_user_id=user.id, invitee_user_id=invitee_id, cash_cents=cents
|
||||
)
|
||||
|
||||
created = _notif_count(db, user.id) - before
|
||||
if created == 1:
|
||||
verdict = "✅ 已落库 1 条站内消息"
|
||||
else:
|
||||
verdict = "⚠️ 未落库(若日志有 dedup hit:该被邀请人上一条还未读,先在 App 里读掉再发)"
|
||||
print(f"\n{verdict};推送成败见上方 shagua.notification_events 日志。")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
REM Push test #4 withdraw_failed (1 notification per run, random amount + reason).
|
||||
REM Works from ANY directory (locates project root + venv python by itself):
|
||||
REM scripts\test_push_withdraw_failed.bat
|
||||
REM Extra args pass through, e.g.: test_push_withdraw_failed.bat --cents 350
|
||||
REM ASCII-only comments: cmd parses .bat in the console codepage (GBK), UTF-8
|
||||
REM Chinese here gets mangled into bogus commands.
|
||||
cd /d "%~dp0.."
|
||||
".venv\Scripts\python.exe" "scripts\test_push_withdraw_failed.py" %*
|
||||
@@ -0,0 +1,106 @@
|
||||
"""#4 提现失败(withdraw_failed)推送联调脚本 —— 每次执行只发 1 条,金额 + 失败原因随机。
|
||||
|
||||
后台虽能驱动提现拒绝,但受「同一用户同时只能有 1 张待审单」约束,批量测试凑不齐单子;
|
||||
本脚本直接调 services/notification_events.notify_withdraw_failed —— 与生产同一条下发链路:
|
||||
落 notification 表(站内消息)+ 向该用户全部已注册厂商 token 直推(honor/huawei/xiaomi/oppo/vivo)。
|
||||
|
||||
可重复性:每次执行用全新 uuid 单号做 dedup_key,永不命中「未读去重」,想跑多少次都行。
|
||||
金额默认每次随机(0.01 ~ 99.99 元)、失败原因随机 → 手机上按金额/原因就能认出这条通知;
|
||||
--cents / --reason 可固定。
|
||||
|
||||
用法(服务端进程无需在跑,脚本自己连库 + 直调厂商接口):
|
||||
.venv\\Scripts\\python.exe scripts\\test_push_withdraw_failed.py # 随机金额+原因发 1 条
|
||||
.venv\\Scripts\\python.exe scripts\\test_push_withdraw_failed.py --cents 350 # 固定 3.50 元
|
||||
.venv\\Scripts\\python.exe scripts\\test_push_withdraw_failed.py --reason "自定义原因" # 固定失败原因
|
||||
|
||||
结果判读(看输出日志):
|
||||
push sent = 厂商接口受理成功,手机应弹「提现失败」通知
|
||||
push failed = 厂商拒绝(原因见日志:token 失效/凭据错误等)
|
||||
skip push = 该厂商凭据未配置,只落站内消息
|
||||
站内消息用 11111111111 登录 App → 消息中心「提现助手」可见;点击应跳提现页(extra.withdrawId)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.models.notification import Notification
|
||||
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")
|
||||
|
||||
TYPE_KEY = "withdraw_failed"
|
||||
|
||||
# 失败原因池:不带 --reason 时随机抽一条,模拟不同失败场景(文案与 /withdraw/status 用户可读口径一致)
|
||||
FAIL_REASONS = [
|
||||
"微信零钱未实名,款项已退回",
|
||||
"收款账户异常,款项已退回",
|
||||
"超出微信零钱收款限额,款项已退回",
|
||||
"微信实名与提现实名不一致,款项已原路退回现金余额",
|
||||
]
|
||||
|
||||
|
||||
def _notif_count(db, uid: int) -> int:
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(Notification.user_id == uid, Notification.type == TYPE_KEY)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="#4 提现失败 推送联调(每次 1 条,金额/原因默认随机)")
|
||||
parser.add_argument("--phone", default="11111111111", help="目标用户手机号(默认 11111111111)")
|
||||
parser.add_argument("--cents", type=int, default=None, help="退回金额,单位分(默认随机 1~9999)")
|
||||
parser.add_argument("--reason", default="", help="失败原因(默认从内置原因池随机抽)")
|
||||
args = parser.parse_args()
|
||||
|
||||
cents = args.cents if args.cents is not None else random.randint(1, 9999)
|
||||
reason = args.reason.strip() or random.choice(FAIL_REASONS)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = user_repo.get_user_by_phone(db, args.phone)
|
||||
if user is None:
|
||||
print(f"❌ 用户 {args.phone} 不存在。先用该手机号在 App 登录一次再跑。")
|
||||
return
|
||||
|
||||
targets = device_repo.list_push_targets(db, user_id=user.id)
|
||||
vendors = [t.push_vendor for t in targets]
|
||||
print(f"目标用户 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}")
|
||||
|
||||
before = _notif_count(db, user.id)
|
||||
order = WithdrawOrder(
|
||||
user_id=user.id, out_bill_no=uuid.uuid4().hex,
|
||||
amount_cents=cents, source="coin_cash", fail_reason=reason,
|
||||
)
|
||||
print(f"→ 本次金额 【{cents / 100:.2f} 元】,原因【{reason}】(单号 {order.out_bill_no[:8]}…)")
|
||||
notification_events.notify_withdraw_failed(db, order)
|
||||
|
||||
created = _notif_count(db, user.id) - before
|
||||
verdict = "✅ 已落库 1 条站内消息" if created == 1 else "⚠️ 未落库(见上方日志)"
|
||||
print(f"\n{verdict};推送成败见上方 shagua.notification_events 日志。")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
REM Push test #3 withdraw_success (1 notification per run, random amount).
|
||||
REM Works from ANY directory (locates project root + venv python by itself):
|
||||
REM scripts\test_push_withdraw_success.bat
|
||||
REM Extra args pass through, e.g.: test_push_withdraw_success.bat --cents 1280
|
||||
REM ASCII-only comments: cmd parses .bat in the console codepage (GBK), UTF-8
|
||||
REM Chinese here gets mangled into bogus commands.
|
||||
cd /d "%~dp0.."
|
||||
".venv\Scripts\python.exe" "scripts\test_push_withdraw_success.py" %*
|
||||
@@ -0,0 +1,94 @@
|
||||
"""#3 提现到账(withdraw_success)推送联调脚本 —— 每次执行只发 1 条,金额随机。
|
||||
|
||||
本环境 wxpay 未配置,后台审核通过发不出微信转账,打不通真实提现链路;本脚本直接调
|
||||
services/notification_events.notify_withdraw_success —— 与生产同一条下发链路:
|
||||
落 notification 表(站内消息)+ 向该用户全部已注册厂商 token 直推(honor/huawei/xiaomi/oppo/vivo)。
|
||||
|
||||
可重复性:每次执行用全新 uuid 单号做 dedup_key,永不命中「未读去重」,想跑多少次都行。
|
||||
金额默认每次随机(0.01 ~ 99.99 元)→ 手机上按金额就能认出这条通知是哪次跑出来的;--cents 可固定。
|
||||
|
||||
用法(服务端进程无需在跑,脚本自己连库 + 直调厂商接口):
|
||||
.venv\\Scripts\\python.exe scripts\\test_push_withdraw_success.py # 随机金额发 1 条
|
||||
.venv\\Scripts\\python.exe scripts\\test_push_withdraw_success.py --cents 1280 # 固定 12.80 元
|
||||
|
||||
结果判读(看输出日志):
|
||||
push sent = 厂商接口受理成功,手机应弹「提现到账」通知
|
||||
push failed = 厂商拒绝(原因见日志:token 失效/凭据错误等)
|
||||
skip push = 该厂商凭据未配置,只落站内消息
|
||||
站内消息用 11111111111 登录 App → 消息中心「提现助手」可见。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.models.notification import Notification
|
||||
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")
|
||||
|
||||
TYPE_KEY = "withdraw_success"
|
||||
|
||||
|
||||
def _notif_count(db, uid: int) -> int:
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(Notification.user_id == uid, Notification.type == TYPE_KEY)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="#3 提现到账 推送联调(每次 1 条,金额默认随机)")
|
||||
parser.add_argument("--phone", default="11111111111", help="目标用户手机号(默认 11111111111)")
|
||||
parser.add_argument("--cents", type=int, default=None, help="到账金额,单位分(默认随机 1~9999)")
|
||||
args = parser.parse_args()
|
||||
|
||||
cents = args.cents if args.cents is not None else random.randint(1, 9999)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = user_repo.get_user_by_phone(db, args.phone)
|
||||
if user is None:
|
||||
print(f"❌ 用户 {args.phone} 不存在。先用该手机号在 App 登录一次再跑。")
|
||||
return
|
||||
|
||||
targets = device_repo.list_push_targets(db, user_id=user.id)
|
||||
vendors = [t.push_vendor for t in targets]
|
||||
print(f"目标用户 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}")
|
||||
|
||||
before = _notif_count(db, user.id)
|
||||
order = WithdrawOrder(
|
||||
user_id=user.id, out_bill_no=uuid.uuid4().hex,
|
||||
amount_cents=cents, source="coin_cash",
|
||||
)
|
||||
print(f"→ 本次金额 【{cents / 100:.2f} 元】(单号 {order.out_bill_no[:8]}…),手机上按金额认领这条通知")
|
||||
notification_events.notify_withdraw_success(db, order)
|
||||
|
||||
created = _notif_count(db, user.id) - before
|
||||
verdict = "✅ 已落库 1 条站内消息" if created == 1 else "⚠️ 未落库(见上方日志)"
|
||||
print(f"\n{verdict};推送成败见上方 shagua.notification_events 日志。")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user