297 lines
12 KiB
Python
297 lines
12 KiB
Python
"""一次性 mock:造几条不同状态的提现单,供运营后台「提现管理」页联调验收。
|
|
|
|
覆盖:
|
|
- 全部 5 个状态 tab(待审核 / 打款中 / 已到账 / 已拒绝 / 打款失败)
|
|
- 「累计提现」列(按该用户 success 单金额求和;有非零也有 0)
|
|
- 空昵称(展示 "-")、提现实名、失败/拒绝原因
|
|
- 配套双分录现金流水(withdraw / withdraw_refund / exchange_in)+ 账户余额,
|
|
让顶部「账本校验」保持绿色、详情抽屉的现金余额/流水也真实。
|
|
|
|
约束:同一 user 可有多笔 reviewing/pending 单,每笔 out_bill_no 唯一且独立对账,
|
|
本脚本每个 mock 用户至多 1 个活动单,满足约束。
|
|
|
|
幂等:每次运行先按固定 mock 手机号清掉旧 mock 再重建。仅清理用 --clean-only。
|
|
|
|
.venv\\Scripts\\python.exe scripts\\seed_mock_withdraws.py
|
|
.venv\\Scripts\\python.exe scripts\\seed_mock_withdraws.py --clean-only
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
import uuid
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import delete, select
|
|
|
|
from app.core import rewards
|
|
from app.db.session import SessionLocal
|
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
|
from app.models.ad_reward import AdRewardRecord
|
|
from app.models.user import User
|
|
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
|
|
|
# Windows GBK 控制台下也能正常打印中文(避免 UnicodeEncodeError)
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
# 固定 mock 手机号:脚本只动这些号,便于幂等重建 / 清理。
|
|
MOCK_PHONES = [
|
|
"13800000001",
|
|
"13900000002",
|
|
"13700000003",
|
|
"13600000004",
|
|
"13500000005",
|
|
"13312345678",
|
|
]
|
|
|
|
REMARK = {"exchange_in": "金币兑入", "withdraw": "提现扣款", "withdraw_refund": "提现退款"}
|
|
|
|
|
|
def _naive_utc_now() -> datetime:
|
|
"""与 func.now() 在 SQLite 的口径一致:naive UTC。前端按 UTC 解析再转北京时间。"""
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def _mock_transfer_no() -> str:
|
|
"""模拟微信转账单号(长串数字)。"""
|
|
return "1330" + str(uuid.uuid4().int)[:26]
|
|
|
|
|
|
def clean(db) -> int:
|
|
uids = list(db.execute(select(User.id).where(User.phone.in_(MOCK_PHONES))).scalars())
|
|
if not uids:
|
|
return 0
|
|
for model in (
|
|
WithdrawOrder,
|
|
CashTransaction,
|
|
CoinTransaction,
|
|
CoinAccount,
|
|
AdRewardRecord,
|
|
AdFeedRewardRecord,
|
|
):
|
|
db.execute(delete(model).where(model.user_id.in_(uids)))
|
|
db.execute(delete(User).where(User.id.in_(uids)))
|
|
db.commit()
|
|
return len(uids)
|
|
|
|
|
|
def seed(db) -> list[WithdrawOrder]:
|
|
now = _naive_utc_now()
|
|
|
|
def ago(**kw) -> datetime:
|
|
return now - timedelta(**kw)
|
|
|
|
# 1) 建用户(手机号 / 昵称;U4 昵称留空测 "-" 展示)
|
|
users_spec = [
|
|
("13800000001", "省钱小能手"),
|
|
("13900000002", "薅羊毛达人"),
|
|
("13700000003", "比价狂魔"),
|
|
("13600000004", None),
|
|
("13500000005", "退款体验官"),
|
|
("13312345678", "已到账老用户"),
|
|
]
|
|
users: dict[str, User] = {}
|
|
for phone, nickname in users_spec:
|
|
u = User(
|
|
phone=phone,
|
|
nickname=nickname,
|
|
register_channel="sms",
|
|
status="active",
|
|
created_at=ago(days=12),
|
|
last_login_at=ago(hours=2),
|
|
)
|
|
db.add(u)
|
|
users[phone] = u
|
|
db.flush() # 拿 user.id
|
|
|
|
# 2) 建提现单(覆盖各状态)
|
|
def order(
|
|
phone: str,
|
|
cents: int,
|
|
status: str,
|
|
*,
|
|
name: str | None = None,
|
|
wechat: str | None = None,
|
|
transfer: str | None = None,
|
|
fail: str | None = None,
|
|
created: datetime,
|
|
updated: datetime | None = None,
|
|
) -> WithdrawOrder:
|
|
return WithdrawOrder(
|
|
user_id=users[phone].id,
|
|
out_bill_no=uuid.uuid4().hex, # 与线上同格式:uuid4 的 32 位 hex
|
|
amount_cents=cents,
|
|
user_name=name,
|
|
status=status,
|
|
wechat_state=wechat,
|
|
transfer_bill_no=transfer,
|
|
fail_reason=fail,
|
|
created_at=created,
|
|
updated_at=updated or created,
|
|
)
|
|
|
|
orders = [
|
|
# U1 省钱小能手:2 已到账 + 1 待审核 → 累计提现 ¥18.00
|
|
order("13800000001", 1000, "success", name="张伟", wechat="SUCCESS",
|
|
transfer=_mock_transfer_no(), created=ago(days=3)),
|
|
order("13800000001", 800, "success", name="张伟", wechat="SUCCESS",
|
|
transfer=_mock_transfer_no(), created=ago(days=5)),
|
|
order("13800000001", 500, "reviewing", name="张伟", created=ago(minutes=3)),
|
|
# U2 薅羊毛达人:1 已到账 + 1 待审核 → 累计提现 ¥20.00
|
|
order("13900000002", 2000, "success", name="李娜", wechat="SUCCESS",
|
|
transfer=_mock_transfer_no(), created=ago(days=2)),
|
|
order("13900000002", 1250, "reviewing", name="李娜", created=ago(minutes=25)),
|
|
# U3 比价狂魔:1 待审核 → 累计提现 ¥0
|
|
order("13700000003", 100, "reviewing", name="王芳", created=ago(hours=1)),
|
|
# U4(空昵称):1 打款中 → 累计提现 ¥0
|
|
order("13600000004", 300, "pending", name="刘洋", wechat="ACCEPTED",
|
|
transfer=_mock_transfer_no(), created=ago(minutes=40), updated=ago(minutes=20)),
|
|
# U5 退款体验官:1 已拒绝 + 1 打款失败 → 累计提现 ¥0
|
|
order("13500000005", 5000, "rejected", name="陈静", fail="微信实名不匹配",
|
|
created=ago(days=1)),
|
|
order("13500000005", 3000, "failed", name="陈静", wechat="FAIL",
|
|
transfer=_mock_transfer_no(), fail="收款账户异常,已退回余额",
|
|
created=ago(hours=6), updated=ago(hours=5)),
|
|
# U6 已到账老用户:2 已到账 → 累计提现 ¥40.00
|
|
order("13312345678", 1500, "success", name="赵强", wechat="SUCCESS",
|
|
transfer=_mock_transfer_no(), created=ago(days=10)),
|
|
order("13312345678", 2500, "success", name="赵强", wechat="SUCCESS",
|
|
transfer=_mock_transfer_no(), created=ago(days=8)),
|
|
]
|
|
db.add_all(orders)
|
|
db.flush()
|
|
|
|
# 3) 配套双分录现金流水 + 账户余额(让账本校验绿、详情抽屉真实):
|
|
# 每单创建时扣款(withdraw, -amount, ref=out_bill_no);失败/拒绝再退款(withdraw_refund, +amount);
|
|
# 每用户起始补一笔 exchange_in 把余额垫正;CoinAccount.cash 余额 = 该用户流水净额。
|
|
by_user: dict[int, list[WithdrawOrder]] = defaultdict(list)
|
|
for o in orders:
|
|
by_user[o.user_id].append(o)
|
|
|
|
for uid, uorders in by_user.items():
|
|
initial = sum(o.amount_cents for o in uorders) + 2000 # 垫 ¥20 余额,确保全程非负
|
|
earliest = min(o.created_at for o in uorders)
|
|
events: list[tuple[datetime, str, int, str | None]] = [
|
|
(earliest - timedelta(days=1), "exchange_in", initial, None)
|
|
]
|
|
for o in uorders:
|
|
events.append((o.created_at, "withdraw", -o.amount_cents, o.out_bill_no))
|
|
if o.status in ("failed", "rejected"):
|
|
events.append((o.updated_at, "withdraw_refund", o.amount_cents, o.out_bill_no))
|
|
events.sort(key=lambda e: e[0])
|
|
|
|
bal = 0
|
|
for t, biz, amt, ref in events:
|
|
bal += amt
|
|
db.add(CashTransaction(
|
|
user_id=uid, amount_cents=amt, balance_after_cents=bal,
|
|
biz_type=biz, ref_id=ref, remark=REMARK[biz], created_at=t,
|
|
))
|
|
db.add(CoinAccount(user_id=uid, coin_balance=0, cash_balance_cents=bal, total_coin_earned=0))
|
|
|
|
# 4) 给部分用户造看广告/签到记录(喂提现详情抽屉的「统计区」+「金币记录」表)。
|
|
# eCPM 原值单位=分/千次;金币用与线上同源的 rewards.calculate_ad_reward_coin 复算,真实偏小。
|
|
def _bj_date(t: datetime) -> str:
|
|
return (t + timedelta(hours=8)).date().isoformat()
|
|
|
|
coin_bal: dict[int, int] = defaultdict(int)
|
|
|
|
def add_video(uid: int, ecpm_fen: int, created: datetime, nth: int, scene: str = "reward_video") -> None:
|
|
db.add(AdRewardRecord(
|
|
trans_id=uuid.uuid4().hex, user_id=uid,
|
|
coin=rewards.calculate_ad_reward_coin(str(ecpm_fen), nth),
|
|
status="granted", reward_scene=scene, ecpm_raw=str(ecpm_fen),
|
|
reward_date=_bj_date(created), created_at=created,
|
|
))
|
|
|
|
def add_feed(uid: int, ecpm_fen: int, units: int, created: datetime, scene: str | None = None) -> None:
|
|
coin = sum(rewards.calculate_ad_reward_coin(str(ecpm_fen), i) for i in range(1, units + 1))
|
|
db.add(AdFeedRewardRecord(
|
|
client_event_id=uuid.uuid4().hex, user_id=uid, reward_date=_bj_date(created),
|
|
duration_seconds=units * 10, unit_count=units, ecpm_raw=str(ecpm_fen),
|
|
feed_scene=scene, coin=coin, status="granted", created_at=created,
|
|
))
|
|
|
|
def add_signin(uid: int, coin: int, created: datetime) -> None:
|
|
coin_bal[uid] += coin
|
|
db.add(CoinTransaction(
|
|
user_id=uid, amount=coin, balance_after=coin_bal[uid],
|
|
biz_type="signin", ref_id=_bj_date(created), remark="每日签到", created_at=created,
|
|
))
|
|
|
|
u1 = users["13800000001"].id # 丰富:激励视频 + 信息流 + 签到 + 1 条签到膨胀
|
|
for i, e in enumerate([3000, 5000, 8000, 4000, 6000, 3500, 7000, 4500, 5500, 6500]):
|
|
add_video(u1, e, ago(days=1, hours=i), nth=(i % 5) + 1)
|
|
add_video(u1, 9000, ago(hours=2), nth=1, scene="signin_boost")
|
|
# 信息流打 scene:比价×2 / 领券×1 / 未分类×1(演示金币记录「途径」分类)
|
|
feed_scenes = ["comparison", "coupon", "comparison", None]
|
|
for i, (e, units) in enumerate([(2000, 6), (3000, 4), (5000, 3), (2500, 5)]):
|
|
add_feed(u1, e, units, ago(days=2, hours=i), scene=feed_scenes[i])
|
|
for i in range(5):
|
|
add_signin(u1, 2000, ago(days=i + 1))
|
|
|
|
u2 = users["13900000002"].id # 较轻:几条激励视频 + 签到
|
|
for i, e in enumerate([3000, 4000, 5000]):
|
|
add_video(u2, e, ago(days=1, hours=i), nth=i + 1)
|
|
for i in range(3):
|
|
add_signin(u2, 2000, ago(days=i + 1))
|
|
|
|
db.commit()
|
|
return orders
|
|
|
|
|
|
def _yuan(cents: int) -> str:
|
|
return f"¥{cents / 100:.2f}"
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="造提现 mock 数据(运营后台提现管理页联调用)")
|
|
parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建")
|
|
args = parser.parse_args()
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
removed = clean(db)
|
|
if removed:
|
|
print(f"🧹 已清理旧 mock:{removed} 个用户及其提现单/流水")
|
|
if args.clean_only:
|
|
print("✅ 仅清理,已完成。")
|
|
return
|
|
|
|
orders = seed(db)
|
|
|
|
# 汇总打印(对照页面应显示的内容)
|
|
cum: dict[int, int] = defaultdict(int)
|
|
for o in orders:
|
|
if o.status == "success":
|
|
cum[o.user_id] += o.amount_cents
|
|
uid2phone = dict(db.execute(select(User.id, User.phone).where(User.phone.in_(MOCK_PHONES))).all())
|
|
|
|
print(f"\n✅ 已生成 {len(orders)} 条提现单,分布:")
|
|
by_status: dict[str, list[WithdrawOrder]] = defaultdict(list)
|
|
for o in orders:
|
|
by_status[o.status].append(o)
|
|
label = {"reviewing": "待审核", "pending": "打款中", "success": "已到账",
|
|
"rejected": "已拒绝", "failed": "打款失败"}
|
|
for st in ("reviewing", "pending", "success", "rejected", "failed"):
|
|
lst = by_status.get(st, [])
|
|
total = sum(o.amount_cents for o in lst)
|
|
print(f" {label[st]:<5} {len(lst)} 单 合计 {_yuan(total)}")
|
|
|
|
print("\n 明细(手机号 | 提现金额 | 累计提现 | 状态 | 失败原因):")
|
|
for o in sorted(orders, key=lambda x: (x.user_id, x.created_at)):
|
|
phone = uid2phone.get(o.user_id, "?")
|
|
print(
|
|
f" {phone} {_yuan(o.amount_cents):>8} 累计 {_yuan(cum.get(o.user_id, 0)):>8} "
|
|
f"{label[o.status]:<5} {o.fail_reason or ''}"
|
|
)
|
|
print("\n👉 打开 http://localhost:3001 → 提现管理 查看。后端若没带 --reload 需重启才有新列。")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|