docs(cps): 群详情每日明细「按天·按用户」领券下钻 设计文档
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# 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"`(北京),**不含年份**。
|
||||
- **群内微信用户**(`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:用户列表与券明细拆两接口、悬停再拉 —— 多一接口、多一往返、有闪烁,对这数据量不值得。)
|
||||
|
||||
## 后端改动(`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": "MM-DD"` 外,**新增 `"date_iso": "YYYY-MM-DD"`**(北京)。下钻必须知道完整日期(`MM-DD` 丢年份,跨年会错)。`group_daily` 已在循环里持有北京日期 `cur`,顺手 `cur.strftime("%Y-%m-%d")` 即可。**纯新增字段、向后兼容**。
|
||||
|
||||
## 前端改动(`src/app/(main)/cps/groups/[id]/page.tsx`)
|
||||
|
||||
1. `DailyRow` 接口加 `date_iso: string`。
|
||||
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_iso, row.date)`。
|
||||
4. 弹窗状态:`{ open, date_iso, label, loading, users }`。`openDayDetail` 置 open+loading,`api.get('/admin/api/cps/groups/${id}/day-users?date=' + date_iso)` → 填 `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}」{label} · 用户领券明细`。空态文案:`仅统计微信授权(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` 通过;明细按钮 → 弹窗加载;悬停点击次数显示券×次数且合计对得上;美团/京东群弹窗走空态文案;昵称为空显示占位。
|
||||
|
||||
## 落点文件
|
||||
|
||||
- 后端:`app/admin/routers/cps.py`(新 endpoint + `/daily` 加 `date_iso`)、`app/admin/repositories/cps.py`(新 `group_day_users` + `group_daily` 补 `date_iso`)。
|
||||
- 前端:`src/app/(main)/cps/groups/[id]/page.tsx`(类型、明细列、Modal、Tooltip)。
|
||||
Reference in New Issue
Block a user