Files
shaguabijia-app-server/scripts/seed_mock_notifications.py
T

421 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""给指定用户(默认手机号 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()