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

133 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CPS 群详情 · 每日明细「按天 · 按用户」领券下钻
日期:2026-06-26
状态:已与用户确认设计,待写实现计划
## 背景
`shaguabijia-admin-web`(纯前端)+ 后端 `shaguabijia-app-server`Admin 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_at``link_id`。一条点击对应的「优惠券」= `link_id → cps_link.activity_id → cps_activity.name`(活动即优惠券)。`cps_wx_user``openid``nickname/headimgurl`
**既有口径(沿用,不改)**:领券次数 = `copy` 事件数;点击次数 = `visit` 事件数。
## 目标
每日明细表每行末尾加「明细」按钮 → 弹窗展示**该群、该天、以用户为单位**的领券情况:
- 列:用户名、领券次数、点击次数。
- 鼠标悬停「点击次数」→ tooltip 展示该用户当天 **visit 过的全部优惠券**,每行 `券名 ×次数`,按次数倒序,**合计 = 点击次数**。
## 方案:A(一个新接口,券明细内联返回)
下钻数据量小(单群 / 单天 / 有 openid 的用户)。新接口一次返回每个用户的领券数、点击数及其 visit 过的券列表,前端一次请求渲染表格 + tooltip,无悬停二次加载、无闪烁。
(已否决方案 B:用户列表与券明细拆两接口、悬停再拉 —— 多一接口、多一往返、有闪烁,对这数据量不值得。)
## 数据库:无需改动
纯读侧聚合,所需数据已全部在库,**不加表 / 不加字段 / 无 migration / 无需回填**
- `cps_click``group_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`)。
- 群不存在 → 404`date` 非法 → 400`detail` 说明格式)。
- **时间窗**`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_user``openid IN (...)`)取 `nickname/headimgurl`(未授权为 null)。
- **排序**`copy_count` desc,再 `visit_count` desc(沿用 `group_wx_users` 习惯)。
- 可选 `limit`(默认 200,与 `group_wx_users` 一致)。
**响应**
```json
{
"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_repo``group_day_users(db, *, group_id, date_start, date_end, limit=200) -> list[dict]`,router 负责日期解析与窗口计算后调用之(与 `group_daily`/`group_wx_users` 的分工一致)。
### 微调既有 `/daily``date` 改全日期(不新增字段)
把每行 `date``"MM-DD"` 改成 **`"YYYY-MM-DD"`**(北京),**不再新增 `date_iso`**。下钻必须知道完整日期(`MM-DD` 丢年份,跨年会错);`date` 直接给全日期,契约更简单、无冗余字段。`group_daily` 已在循环里持有北京日期 `cur`,把 `cur.strftime("%m-%d")` 改成 `cur.strftime("%Y-%m-%d")` 即可。
- 该 endpoint 唯一消费方是本前端(随本次一起改),故**非破坏性**。
- 折线图/时序用的是 `/timeseries``label` 字段(另一套,仍 `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. 新增类型:
```ts
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"` 小按钮,`onClick` → `openDayDetail(row.date)`(全日期既用于查询也用于弹窗标题)。
4. 弹窗状态:`{ open, date, loading, users }`。`openDayDetail(date)` 置 open+loading`api.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 非空用户;②某用户 `coupons` 的 `count` 之和 = 其 `visit_count`;③`date` 非法返回 400、群不存在返回 404;④跨年日期(如去年 12-31)能取到正确那天。
- 前端:`npm run build` 通过;日期列完整显示 `YYYY-MM-DD`、列宽不挤;明细按钮 → 弹窗加载;悬停点击次数显示券×次数且合计对得上;美团/京东群弹窗走空态文案;昵称为空显示占位。
## 落点文件
- 后端:`app/admin/routers/cps.py`(新 endpoint + `/daily` 的 `date` 改 `YYYY-MM-DD`)、`app/admin/repositories/cps.py`(新 `group_day_users` + `group_daily` 的 `date` 改全日期)。
- 前端:`src/app/(main)/cps/groups/[id]/page.tsx`(类型、日期列加宽、明细列、Modal、Tooltip)。