3dca126411
微信优惠券增加美团/京东中间页,领券前获取微信头像授权(主功能) --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #75 Co-authored-by: guke <guke@wonderable.ai> Co-committed-by: guke <guke@wonderable.ai>
338 lines
16 KiB
Python
338 lines
16 KiB
Python
"""一次性 mock:造 CPS「按群对账统计」+「群内微信用户(领券画像)」所需假数据。
|
|
|
|
覆盖两个后台接口:
|
|
• get_stats (GET /admin/cps/stats) → cps_repo.group_stats
|
|
• group_wx_users (GET /admin/cps/groups/{id}/wx-users) → cps_repo.group_wx_users
|
|
|
|
涉及四张表:
|
|
- cps_group 群(active 才入统计;sid 仅美团群有)
|
|
- cps_order 对账订单(按 sid 归群;status 2付款 3完成=有效 / 4取消 5风控=无效 / 6结算)
|
|
- cps_click 点击(group_stats 按 group_id 聚合 pv/uv;group_wx_users 只看带 openid 的点击)
|
|
- cps_wx_user 落地页微信用户画像(nickname/headimgurl;userinfo 授权后才有)
|
|
|
|
覆盖 group_stats 全部分支:① active 美团群(对账) ② 美团+淘宝多平台 ③ 纯淘宝/京东(无 sid,
|
|
对账字段 None) ④ archived 群(被过滤) ⑤ 未归群历史 sid(group_id=None 单列)。
|
|
|
|
覆盖 group_wx_users 全部分支(挂在淘宝系群 B/C —— 仅淘宝落地页有"复制口令"触发 userinfo):
|
|
• 有完整画像(昵称+可加载头像)的领券用户(visit+copy)
|
|
• 只 visit 不 copy 的用户(无领券意向)
|
|
• 跨群用户(在 C 首次授权、又来 B 活动)→ 验证"按本群点击判定归属,而非 first_group_id"
|
|
• 有 openid 但无画像(base 静默授权)→ nickname/headimgurl 返回 None(前端走色块兜底)
|
|
|
|
头像:本地手写纯色 PNG 落 data/media/cps_wx/,headimgurl 存绝对 URL(经 App 后端 :8770 的
|
|
/media 托管),后台 <img> 可直接加载。时间均落最近几天(默认窗口内),tz-aware UTC。
|
|
|
|
幂等:重跑先按 mock 标识(群名前缀「【MOCK】」/ 订单号前缀「MOCKCPS-」/ openid 前缀
|
|
「oMOCKcps」)清旧再重建;点击/短链按 mock 群 id 清,头像文件按 glob 清。--clean-only 只清。
|
|
|
|
python -m scripts.seed_mock_cps_stats
|
|
python -m scripts.seed_mock_cps_stats --clean-only
|
|
|
|
看数据:后台「CPS / 按群对账统计」页 + 群详情「群内微信用户」,或:
|
|
GET /admin/cps/stats?days=30
|
|
GET /admin/cps/groups/{B或C的id}/wx-users
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import struct
|
|
import sys
|
|
import zlib
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import delete, select
|
|
|
|
from app.core.config import settings
|
|
from app.db.session import SessionLocal
|
|
from app.models.cps_group import CpsGroup
|
|
from app.models.cps_link import CpsClick, CpsLink
|
|
from app.models.cps_order import CpsOrder
|
|
from app.models.cps_wx_user import CpsWxUser
|
|
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥
|
|
|
|
# ── mock 标识(只动带这些标识的数据,便于幂等清理)──
|
|
NAME_PREFIX = "【MOCK】" # 群名前缀
|
|
ORDER_PREFIX = "MOCKCPS-" # 订单号前缀
|
|
OPENID_PREFIX = "oMOCKcps" # 微信用户 openid 前缀
|
|
SID_MT_A = "mockcpsmta" # 群A sid(美团);sid 仅字母+数字
|
|
SID_MT_B = "mockcpsmtb" # 群B sid(美团+淘宝)
|
|
SID_ARCHIVED = "mockcpsmtz" # 已归档群 sid
|
|
SID_ORPHAN = "mockcpsold" # 未归群历史 sid(不属于任何群)
|
|
|
|
# 头像:本地纯色 PNG 落 media/cps_wx/,headimgurl 存「绝对 URL」(微信场景本就是完整外链,
|
|
# 后台直接当 <img src>);本地由 App 后端 :8770 托管 /media。
|
|
APP_MEDIA_BASE = "http://localhost:8770"
|
|
_AVATAR_DIR = Path(settings.MEDIA_ROOT) / "cps_wx"
|
|
_AVATAR_GLOB = "mock_wx_*.png"
|
|
|
|
# 微信用户画像: (openid 后缀, 昵称|None=无画像, 头像色, 首次授权群 key)
|
|
WX_PROFILES = [
|
|
("c1", "省钱小张", (250, 173, 20), "C"),
|
|
("c2", "薅羊毛阿珍", (82, 196, 26), "C"),
|
|
("c3", "随便看看", (24, 144, 255), "C"),
|
|
("sh", "比价小能手", (114, 46, 209), "C"), # 跨群:C 首授权,也来 B
|
|
("b1", "深夜下单选手", (245, 34, 45), "B"),
|
|
("b2", "团购队长", (19, 194, 194), "B"),
|
|
("anon", None, None, "C"), # 无画像(base 静默授权,只有 openid)
|
|
]
|
|
# 每个用户在各群的点击活动: (群 key, openid 后缀, visit 次数, copy 次数)
|
|
WX_ACTIVITY = [
|
|
("C", "c1", 3, 2), ("C", "c2", 2, 1), ("C", "c3", 1, 0),
|
|
("C", "sh", 5, 3), ("C", "anon", 2, 1),
|
|
("B", "b1", 2, 1), ("B", "b2", 3, 0), ("B", "sh", 2, 1), # sh 跨群
|
|
]
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _openid(suffix: str) -> str:
|
|
return f"{OPENID_PREFIX}_{suffix}"
|
|
|
|
|
|
def _solid_png(rgb: tuple[int, int, int], size: int = 96) -> bytes:
|
|
"""纯色 PNG(truecolor RGB)字节,无需 Pillow。色块即可肉眼判断头像加载出来了。"""
|
|
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", size, size, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB
|
|
idat = zlib.compress((b"\x00" + bytes(rgb) * size) * size, 9)
|
|
return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
|
|
|
|
|
|
def _write_avatar(suffix: str, rgb: tuple[int, int, int]) -> str:
|
|
"""写一张 mock 头像到 media/cps_wx/,返回可加载的绝对 URL。"""
|
|
_AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
|
name = f"mock_wx_{suffix}.png"
|
|
(_AVATAR_DIR / name).write_bytes(_solid_png(rgb))
|
|
return f"{APP_MEDIA_BASE}{settings.MEDIA_URL_PREFIX}/cps_wx/{name}"
|
|
|
|
|
|
def clean(db) -> dict:
|
|
"""按 mock 标识清旧数据。点击/短链按 mock 群 id 清,订单按号前缀清。"""
|
|
mock_gids = list(
|
|
db.execute(select(CpsGroup.id).where(CpsGroup.name.like(f"{NAME_PREFIX}%"))).scalars()
|
|
)
|
|
n_click = n_link = n_group = 0
|
|
if mock_gids:
|
|
n_click = db.execute(
|
|
delete(CpsClick).where(CpsClick.group_id.in_(mock_gids))
|
|
).rowcount or 0
|
|
n_link = db.execute(
|
|
delete(CpsLink).where(CpsLink.group_id.in_(mock_gids))
|
|
).rowcount or 0
|
|
n_group = db.execute(
|
|
delete(CpsGroup).where(CpsGroup.id.in_(mock_gids))
|
|
).rowcount or 0
|
|
n_order = db.execute(
|
|
delete(CpsOrder).where(CpsOrder.order_id.like(f"{ORDER_PREFIX}%"))
|
|
).rowcount or 0
|
|
n_wx = db.execute(
|
|
delete(CpsWxUser).where(CpsWxUser.openid.like(f"{OPENID_PREFIX}%"))
|
|
).rowcount or 0
|
|
db.commit()
|
|
# 删 mock 头像文件
|
|
if _AVATAR_DIR.exists():
|
|
for f in _AVATAR_DIR.glob(_AVATAR_GLOB):
|
|
f.unlink(missing_ok=True)
|
|
return {"group": n_group, "order": n_order, "click": n_click, "link": n_link, "wx_user": n_wx}
|
|
|
|
|
|
def _add_clicks(db, *, group_id: int, sid: str | None, link_id: int,
|
|
visit: int, visit_uv: int, copy: int = 0, copy_uv: int = 0,
|
|
now: datetime) -> None:
|
|
"""造点击:visit 条 visit 事件(visit_uv 个去重身份循环)+ copy 条 copy 事件。
|
|
pv = 总条数,uv = distinct(ip,ua) = min(uv_arg, 条数)。clicked_at 摊在最近 6 天。"""
|
|
def emit(n: int, uv: int, event: str) -> None:
|
|
uv = max(1, min(uv, n)) if n else 0
|
|
for k in range(n):
|
|
ident = k % uv # 循环复用身份 → pv>uv
|
|
db.add(CpsClick(
|
|
link_id=link_id, group_id=group_id, sid=sid, event_type=event,
|
|
ip=f"10.{group_id}.{event[0]}.{ident}",
|
|
ua=f"MockUA/{event}-{ident}",
|
|
clicked_at=now - timedelta(days=k % 6, hours=k),
|
|
))
|
|
emit(visit, visit_uv, "visit")
|
|
emit(copy, copy_uv, "copy")
|
|
|
|
|
|
def _add_orders(db, *, sid: str, specs: list[tuple[str, int, int, str]], now: datetime,
|
|
tag: str) -> None:
|
|
"""specs: [(mt_status, pay_price_cents, commission_cents, product_name), ...]"""
|
|
for i, (status, pay, comm, name) in enumerate(specs):
|
|
db.add(CpsOrder(
|
|
order_id=f"{ORDER_PREFIX}{tag}-{i:02d}",
|
|
sid=sid, act_id="100001", biz_line=1, trade_type=1,
|
|
pay_price_cents=pay, commission_cents=comm, commission_rate="500",
|
|
refund_price_cents=None, refund_profit_cents=None,
|
|
mt_status=status, product_name=name,
|
|
pay_time=now - timedelta(days=i % 6, hours=i),
|
|
mt_update_time=now - timedelta(days=i % 6),
|
|
raw={"mock": True, "status": status},
|
|
))
|
|
|
|
|
|
def seed(db) -> dict:
|
|
now = _now()
|
|
|
|
# ── 1) 建群(覆盖 active 美团 / 美团+淘宝 / 纯淘宝 / 纯京东 / archived)──
|
|
groups = [
|
|
CpsGroup(sid=SID_MT_A, name=f"{NAME_PREFIX}美团外卖群A",
|
|
platforms=["meituan"], member_count=480, status="active",
|
|
remark="mock-纯美团,走对账"),
|
|
CpsGroup(sid=SID_MT_B, name=f"{NAME_PREFIX}美团+淘宝群B",
|
|
platforms=["meituan", "taobao"], member_count=320, status="active",
|
|
remark="mock-多平台,对账+复制口令"),
|
|
CpsGroup(sid=None, name=f"{NAME_PREFIX}淘宝券群C",
|
|
platforms=["taobao"], member_count=210, status="active",
|
|
remark="mock-无 sid,对账字段 None"),
|
|
CpsGroup(sid=None, name=f"{NAME_PREFIX}京东券群D",
|
|
platforms=["jd"], member_count=150, status="active",
|
|
remark="mock-无 sid,对账字段 None"),
|
|
CpsGroup(sid=SID_ARCHIVED, name=f"{NAME_PREFIX}已归档美团群E",
|
|
platforms=["meituan"], member_count=90, status="archived",
|
|
remark="mock-archived,应被统计排除"),
|
|
]
|
|
db.add_all(groups)
|
|
db.flush() # 拿 group.id
|
|
g_a, g_b, g_c, g_d, g_e = groups
|
|
|
|
# ── 2) 每群一条短链(cps_click.link_id 非空;无 FK,activity_id 占位即可)──
|
|
links = {}
|
|
for g in groups:
|
|
link = CpsLink(
|
|
code=f"MK{g.id:05d}", group_id=g.id, activity_id=0,
|
|
sid=g.sid, platform=(g.platforms[0] if g.platforms else "meituan"),
|
|
target_url=f"https://example.com/mock/{g.id}",
|
|
)
|
|
db.add(link)
|
|
links[g.id] = link
|
|
db.flush() # 拿 link.id
|
|
|
|
# ── 3) 点击(pv>uv;淘宝群带 copy 复制口令)──
|
|
_add_clicks(db, group_id=g_a.id, sid=g_a.sid, link_id=links[g_a.id].id,
|
|
visit=5, visit_uv=3, now=now)
|
|
_add_clicks(db, group_id=g_b.id, sid=g_b.sid, link_id=links[g_b.id].id,
|
|
visit=8, visit_uv=5, copy=3, copy_uv=2, now=now)
|
|
_add_clicks(db, group_id=g_c.id, sid=None, link_id=links[g_c.id].id,
|
|
visit=6, visit_uv=4, copy=4, copy_uv=3, now=now)
|
|
_add_clicks(db, group_id=g_d.id, sid=None, link_id=links[g_d.id].id,
|
|
visit=4, visit_uv=3, now=now)
|
|
_add_clicks(db, group_id=g_e.id, sid=g_e.sid, link_id=links[g_e.id].id,
|
|
visit=3, visit_uv=2, now=now) # archived,统计里看不到
|
|
|
|
# ── 3.5) 微信用户画像 + 带 openid 的点击(挂淘宝系群 B/C;这些点击也计入 stats 的 pv/uv)──
|
|
groups_by_key = {"A": g_a, "B": g_b, "C": g_c, "D": g_d, "E": g_e}
|
|
# 建画像;无昵称的 anon 不建 CpsWxUser → group_wx_users 里 nickname/headimgurl 为 None
|
|
for i, (suf, nickname, rgb, fg_key) in enumerate(WX_PROFILES):
|
|
if nickname is None:
|
|
continue
|
|
fg = groups_by_key[fg_key]
|
|
db.add(CpsWxUser(
|
|
openid=_openid(suf), nickname=nickname, headimgurl=_write_avatar(suf, rgb),
|
|
first_code=links[fg.id].code, first_group_id=fg.id,
|
|
first_seen=now - timedelta(days=3, hours=i), last_seen=now - timedelta(hours=1),
|
|
))
|
|
# 逐条 openid 点击:同一 openid 固定一个 (ip,ua) 身份(跨群复用=同人同设备)
|
|
idx_of = {_openid(suf): i for i, (suf, *_rest) in enumerate(WX_PROFILES)}
|
|
counter = [0]
|
|
|
|
def _wx_clicks(group: CpsGroup, openid: str, visit: int, copy: int) -> None:
|
|
ident = idx_of[openid]
|
|
ip, ua = f"100.64.{ident}.{ident}", f"WeChat/8.0 {openid}"
|
|
for event, n in (("visit", visit), ("copy", copy)):
|
|
for _ in range(n):
|
|
counter[0] += 1
|
|
db.add(CpsClick(
|
|
link_id=links[group.id].id, group_id=group.id, sid=group.sid,
|
|
event_type=event, ip=ip, ua=ua, openid=openid,
|
|
clicked_at=now - timedelta(hours=counter[0]),
|
|
))
|
|
|
|
for gk, suf, visit, copy in WX_ACTIVITY:
|
|
_wx_clicks(groups_by_key[gk], _openid(suf), visit, copy)
|
|
|
|
# ── 4) 订单(按 sid 归群;状态覆盖 有效/无效/结算)──
|
|
# 群A:有效5(2,3,3,6,6) 结算2 取消2(4,5)
|
|
_add_orders(db, sid=SID_MT_A, tag="A", now=now, specs=[
|
|
("2", 3580, 180, "麦当劳 板烧鸡腿堡套餐"),
|
|
("3", 4200, 210, "瑞幸 生椰拿铁×2"),
|
|
("3", 2890, 140, "肯德基 早餐组合"),
|
|
("6", 5100, 255, "海底捞 双人套餐"),
|
|
("6", 3300, 165, "蜜雪冰城 多杯装"),
|
|
("4", 2600, 130, "(取消)星巴克 美式"),
|
|
("5", 1990, 90, "(风控)某外卖单"),
|
|
])
|
|
# 群B:有效3(3,6,2) 结算1 取消1(4)
|
|
_add_orders(db, sid=SID_MT_B, tag="B", now=now, specs=[
|
|
("3", 6800, 340, "必胜客 双人餐"),
|
|
("6", 4500, 225, "喜茶 多肉葡萄×3"),
|
|
("2", 3120, 150, "华莱士 全鸡套餐"),
|
|
("4", 2200, 110, "(取消)某甜品单"),
|
|
])
|
|
# 未归群历史 sid(不属于任何群)→ 结果里 group_id=None 单列
|
|
_add_orders(db, sid=SID_ORPHAN, tag="OLD", now=now, specs=[
|
|
("6", 2800, 140, "历史结算单"),
|
|
("3", 3600, 180, "历史完成单"),
|
|
])
|
|
|
|
db.commit()
|
|
return {
|
|
"groups": len(groups), "orders": 7 + 4 + 2,
|
|
"anon_clicks": 5 + (8 + 3) + (6 + 4) + 4 + 3,
|
|
"wx_clicks": sum(v + c for *_, v, c in WX_ACTIVITY),
|
|
"wx_users": len({suf for _, suf, *_ in WX_ACTIVITY}),
|
|
"wx_profiles": sum(1 for _, nn, *_ in WX_PROFILES if nn is not None),
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="造 CPS 按群对账统计 mock 数据(后台 get_stats 用)")
|
|
parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建")
|
|
args = parser.parse_args()
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
removed = clean(db)
|
|
if any(removed.values()):
|
|
print(f"🧹 已清理旧 mock:群{removed['group']} 订单{removed['order']} "
|
|
f"点击{removed['click']} 短链{removed['link']} 微信用户{removed['wx_user']}")
|
|
if args.clean_only:
|
|
print("✅ 仅清理,已完成。")
|
|
return
|
|
|
|
r = seed(db)
|
|
print(f"\n✅ 已生成:{r['groups']} 群 / {r['orders']} 订单 / "
|
|
f"{r['anon_clicks'] + r['wx_clicks']} 点击(其中带 openid {r['wx_clicks']}) / "
|
|
f"{r['wx_users']} 微信用户(含画像 {r['wx_profiles']})\n")
|
|
print("① 预期 get_stats(days=30)(B/C 的 pv/uv 已含带 openid 的用户点击):")
|
|
print(" 群A(美团,sid=%s) : 有效5 结算2 取消2 | GMV ¥190.70 预估 ¥9.50 已结算 ¥4.20 | 点击 pv5/uv3"
|
|
% SID_MT_A)
|
|
print(" 群B(美团+淘宝,sid=%s): 有效3 结算1 取消1 | GMV ¥144.20 预估 ¥7.15 已结算 ¥2.25 | 点击 pv15/uv8 复制 pv5/uv4"
|
|
% SID_MT_B)
|
|
print(" 群C(淘宝,无 sid) : 对账字段 '-' | 点击 pv19/uv9 复制 pv11/uv7")
|
|
print(" 群D(京东,无 sid) : 对账字段 '-' | 点击 pv4/uv3")
|
|
print(" 群E(已归档) : 不出现在结果(status 过滤)")
|
|
print(" 未归群 sid=%s : group_id=None 单列 | 有效2 结算1 | GMV ¥64.00 预估 ¥3.20 已结算 ¥1.40"
|
|
% SID_ORPHAN)
|
|
print(" 合计:订单 10 | 预估佣金 ¥19.85 | 已结算佣金 ¥7.85")
|
|
print("\n② 预期 group_wx_users(按本群首次点击倒序):")
|
|
print(" 群C(5 人): 省钱小张 v3/c2 | 薅羊毛阿珍 v2/c1 | 随便看看 v1/c0 | "
|
|
"比价小能手 v5/c3 | (匿名·无画像) v2/c1")
|
|
print(" 群B(3 人): 深夜下单选手 v2/c1 | 团购队长 v3/c0 | 比价小能手 v2/c1(跨群,首授权在 C)")
|
|
print(" 群A/D/E : 无带 openid 点击 → 返回 []")
|
|
print("\n👉 验收:GET /admin/cps/stats?days=30")
|
|
print(" GET /admin/cps/groups/{群B或C的id}/wx-users(头像经 :8770 /media 加载,App 后端需在跑)")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|