"""一次性 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 托管),后台
可直接加载。时间均落最近几天(默认窗口内),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」(微信场景本就是完整外链,
# 后台直接当
);本地由 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()