421 lines
19 KiB
Python
421 lines
19 KiB
Python
"""给指定用户(默认手机号 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()
|