Files
shaguabijia-admin-web/docs/superpowers/specs/2026-06-26-cps-day-user-drilldown-design.md
T
guke 9b7a1aef8a docs(cps): 群详情每日明细「按天·按用户」领券下钻 设计文档 (#19)
增加微信用户每日领券明细

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #19
2026-06-26 15:18:25 +08:00

9.4 KiB
Raw Blame History

CPS 群详情 · 每日明细「按天 · 按用户」领券下钻

日期:2026-06-26 状态:已与用户确认设计,待写实现计划

背景

shaguabijia-admin-web(纯前端)+ 后端 shaguabijia-app-serverAdmin API/admin/api/*,端口 8771)。

CPS 群对账详情页 src/app/(main)/cps/groups/[id]/page.tsx 现有两张表:

  • 每日明细GET /admin/api/cps/groups/{id}/daily):按天一行,含点击/复制/订单佣金。行 date 原为 "MM-DD"(北京),下钻要完整日期 → 本次改为 "YYYY-MM-DD"(见后端改动)。
  • 群内微信用户GET /admin/api/cps/groups/{id}/wx-users):累计(非按天)每用户画像,列含 用户 / 领券次数(copy_count) / 点击次数(visit_count)。

底层数据 cps_click(一事件一行):event_type = visit(进落地页/被跳转) | copy(淘宝"复制口令")、openid(微信授权才有)、clicked_atlink_id。一条点击对应的「优惠券」= link_id → cps_link.activity_id → cps_activity.name(活动即优惠券)。cps_wx_useropenidnickname/headimgurl

既有口径(沿用,不改):领券次数 = copy 事件数;点击次数 = visit 事件数。

目标

每日明细表每行末尾加「明细」按钮 → 弹窗展示该群、该天、以用户为单位的领券情况:

  • 列:用户名、领券次数、点击次数。
  • 鼠标悬停「点击次数」→ tooltip 展示该用户当天 visit 过的全部优惠券,每行 券名 ×次数,按次数倒序,合计 = 点击次数

方案:A(一个新接口,券明细内联返回)

下钻数据量小(单群 / 单天 / 有 openid 的用户)。新接口一次返回每个用户的领券数、点击数及其 visit 过的券列表,前端一次请求渲染表格 + tooltip,无悬停二次加载、无闪烁。

(已否决方案 B:用户列表与券明细拆两接口、悬停再拉 —— 多一接口、多一往返、有闪烁,对这数据量不值得。)

数据库:无需改动

纯读侧聚合,所需数据已全部在库,不加表 / 不加字段 / 无 migration / 无需回填

  • cps_clickgroup_id(idx) / clicked_at(idx) / openid(idx) / event_type / link_id —— 筛群、筛天、分用户、分领券/点击、定位券所需字段齐全。
  • 券归属 link_id → cps_link.activity_id → cps_activity.name写入时就落了(record_click 一直写 link_id),历史点击可反查,无需回填
  • 查询条件 group_id + clicked_at 区间 + openid IS NOT NULL 均命中现有单列索引;数据量小、Python 侧聚合,无需新增复合索引

后端改动(app/admin/routers/cps.py + app/admin/repositories/cps.py

新增接口 GET /admin/api/cps/groups/{group_id}/day-users

  • 入参:date(必填,YYYY-MM-DD)。
  • 鉴权:Depends(get_current_admin)(只读,登录即可;与 /daily/wx-users 同口径,不加 require_role)。
  • 群不存在 → 404date 非法 → 400detail 说明格式)。
  • 时间窗date 按北京时区(UTC+8)解释成 [当天 00:00, 次日 00:00) 半开区间,转 UTC 后查 clicked_at >= start AND clicked_at < next_day_start(半开避免午夜双计)。北京日界 → UTC 用既有 _as_utc 约定,与 group_click_timeseries 分桶口径一致。
  • 取数cps_click where group_id == {group_id} AND 时间窗内 AND openid IS NOT NULL
  • 解析券名:取该群相关 cps_link(或按出现的 link_id 批量取)建 link_id → activity_id 映射,再 activity_id → cps_activity.name 映射;Python 侧聚合(与本仓既有风格一致)。活动被硬删 → 券名兜底 活动#{activity_id}
  • 按 openid 聚合
    • copy_count = 该用户 copy 事件数(= 领券次数)
    • visit_count = 该用户 visit 事件数(= 点击次数)
    • coupons = 该用户 visit 事件按活动分组的 {name, count} 列表,按 count 倒序;copy 事件不计入券列表(与用户选择"只算 visit"一致)
  • 关联 cps_wx_useropenid IN (...))取 nickname/headimgurl(未授权为 null)。
  • 排序copy_count desc,再 visit_count desc(沿用 group_wx_users 习惯)。
  • 可选 limit(默认 200,与 group_wx_users 一致)。

响应

{
  "group_id": 1,
  "group_name": "宝妈薅羊毛1群",
  "date": "2026-06-25",
  "users": [
    {
      "openid": "oABC...",
      "nickname": "张三",
      "headimgurl": "https://thirdwx.qlogo.cn/...",
      "copy_count": 2,
      "visit_count": 5,
      "coupons": [
        { "name": "618神券", "count": 3 },
        { "name": "买一送一", "count": 2 }
      ]
    }
  ]
}

实现建议:在 cps_repogroup_day_users(db, *, group_id, date_start, date_end, limit=200) -> list[dict],router 负责日期解析与窗口计算后调用之(与 group_daily/group_wx_users 的分工一致)。

微调既有 /dailydate 改全日期(不新增字段)

把每行 date"MM-DD" 改成 "YYYY-MM-DD"(北京),不再新增 date_iso。下钻必须知道完整日期(MM-DD 丢年份,跨年会错);date 直接给全日期,契约更简单、无冗余字段。group_daily 已在循环里持有北京日期 cur,把 cur.strftime("%m-%d") 改成 cur.strftime("%Y-%m-%d") 即可。

  • 该 endpoint 唯一消费方是本前端(随本次一起改),故非破坏性
  • 折线图/时序用的是 /timeserieslabel 字段(另一套,仍 MM-DD),不受影响,本次不动。

前端改动(src/app/(main)/cps/groups/[id]/page.tsx

  1. DailyRow.date 现为 "YYYY-MM-DD"(类型仍 string,无需加字段)。日期列 { title:'日期', dataIndex:'date' } 宽度 64 → 调宽到约 96,完整显示 YYYY-MM-DD(跨月/跨年看范围更直观)。表格 scroll.x 随新列 + 加宽日期列相应上调(约 980)。
  2. 新增类型:
    interface DayCoupon { name: string; count: number; }
    interface DayUser {
      openid: string; nickname: string | null; headimgurl: string | null;
      copy_count: number; visit_count: number; coupons: DayCoupon[];
    }
    
  3. dailyColumns 末尾加**「明细」列**key:'drill', fixed:'right', width≈64),render 一个 type="link" 小按钮,onClickopenDayDetail(row.date)(全日期既用于查询也用于弹窗标题)。
  4. 弹窗状态:{ open, date, loading, users }openDayDetail(date) 置 open+loadingapi.get('/admin/api/cps/groups/${id}/day-users?date=' + date) → 填 users;失败 message.error(errMsg(e, ...))
  5. 新增 <Modal>(声明式组件,不受 antd5 静态方法 context 限制,无需 App.useApp())含 <Table>,三列:
    • 用户:头像 + 昵称(空 → (未授权昵称)),与既有「群内微信用户」表的用户单元一致(头像直接 <img src={headimgurl}>,微信 URL 为绝对地址,非 /media)。
    • 领券次数copy_count,沿用既有红色加粗样式(0 显示 0)。
    • 点击次数visit_count,整格用 <Tooltip> 包裹,title 渲染 coupons 每行 券名 ×次数coupons 为空时不挂 tooltip(直接显示数字)。
  6. Modal 标题:群「{group_name}」{date} · 用户领券明细。空态文案:仅统计微信授权(openid)用户;美团/京东多为匿名跳转,可能为空
  7. antd 引入新增 Modal, Tooltip

默认与边界(已确认)

  • 匿名点击(openid 为空)不计入(无法归属到人),镜像既有 /wx-users。后果:美团/京东群弹窗常为空 —— 空态文案已提示。
  • 数据口径差异(需在代码注释点明,避免误读为 bug):每日明细行的 click_pv/copy_pv全部点击(含匿名、UV 按 (ip,ua));本弹窗只计 openid 用户。故弹窗各用户求和 当天行的点击/复制总数,二者不必相等。
  • 谁出现在弹窗:当天有任意事件(visit 或 copy)的授权用户都列(含领券数为 0 的纯点击用户)。
  • 领券次数不做 tooltip:需求只要点击次数的 tooltip,保持最小。
  • 权限:不加角色门槛(与本页其它只读统计一致)。

不做(YAGNI

  • 领券次数的「领了哪些券」tooltip。
  • 匿名点击的聚合行 / 占位行。
  • 用户级下单归因(需 user-level sid,另一期)。

验证(本仓无测试框架,手动验证)

  • 后端:选一个有 cps_click 数据的淘宝群,curl .../day-users?date=<某天>,核对:①只含 openid 非空用户;②某用户 couponscount 之和 = 其 visit_count;③date 非法返回 400、群不存在返回 404;④跨年日期(如去年 12-31)能取到正确那天。
  • 前端:npm run build 通过;日期列完整显示 YYYY-MM-DD、列宽不挤;明细按钮 → 弹窗加载;悬停点击次数显示券×次数且合计对得上;美团/京东群弹窗走空态文案;昵称为空显示占位。

落点文件

  • 后端:app/admin/routers/cps.py(新 endpoint + /dailydateYYYY-MM-DD)、app/admin/repositories/cps.py(新 group_day_users + group_dailydate 改全日期)。
  • 前端:src/app/(main)/cps/groups/[id]/page.tsx(类型、日期列加宽、明细列、Modal、Tooltip)。