diff --git a/docs/superpowers/specs/2026-06-26-cps-day-user-drilldown-design.md b/docs/superpowers/specs/2026-06-26-cps-day-user-drilldown-design.md new file mode 100644 index 0000000..997a465 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-cps-day-user-drilldown-design.md @@ -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. 新增 ``(声明式组件,不受 antd5 静态方法 context 限制,无需 `App.useApp()`)含 ``,三列: + - **用户**:头像 + 昵称(空 → `(未授权昵称)`),与既有「群内微信用户」表的用户单元一致(头像直接 ``,微信 URL 为绝对地址,非 `/media`)。 + - **领券次数**:`copy_count`,沿用既有红色加粗样式(0 显示 0)。 + - **点击次数**:`visit_count`,整格用 `` 包裹,`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)。