diff --git a/docs/superpowers/plans/2026-06-26-cps-day-user-drilldown.md b/docs/superpowers/plans/2026-06-26-cps-day-user-drilldown.md new file mode 100644 index 0000000..013c55f --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-cps-day-user-drilldown.md @@ -0,0 +1,746 @@ +# CPS 群详情 · 每日明细「按天 · 按用户」领券下钻 — 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 CPS 群详情页每日明细表每行加「明细」按钮,点开弹窗展示该群该天以用户为单位的领券/点击,并在「点击次数」上悬停展示该用户当天点过的优惠券(券名 ×次数)。 + +**Architecture:** 后端 `shaguabijia-app-server` 新增只读聚合接口 `/admin/api/cps/groups/{id}/day-users?date=YYYY-MM-DD`(券明细内联返回),并把既有 `/daily` 的 `date` 由 `MM-DD` 改全为 `YYYY-MM-DD` 供下钻定位整天。前端 `shaguabijia-admin-web` 加明细列 + Modal + Tooltip,一次请求渲染。纯读侧聚合,**不动数据库**。 + +**Tech Stack:** 后端 FastAPI + SQLAlchemy + pytest(`.venv`);前端 Next.js 15 + Ant Design 5 + TypeScript(无测试框架,门槛为 `npm run build`)。 + +**配套 spec:** `docs/superpowers/specs/2026-06-26-cps-day-user-drilldown-design.md` + +--- + +## 仓库与分支 + +- **前端** `e:\project\shaguabijia-admin-web`:已在分支 `feat/cps-day-user-drilldown`。 +- **后端** `e:\project\shaguabijia-app-server`:独立 git 仓,需自建同名分支(Task 1 Step 0)。 + +后端命令统一**从后端仓根目录运行**;git 用 `git -C e:\project\shaguabijia-app-server ...` 避免切目录。 + +## File Structure + +| 文件 | 改动 | 职责 | +|---|---|---| +| `app/admin/routers/cps.py`(后端) | 改 1 行 + 加 1 个 endpoint | `/daily` 的 `date` 改全日期;新增 `/day-users` 路由(鉴权、日期解析、窗口计算) | +| `app/admin/repositories/cps.py`(后端) | 加 1 个函数 + 补 1 处 import | `group_day_users(...)`:按 openid 聚合领券/点击 + 券明细(Python 侧) | +| `tests/test_cps_admin.py`(后端) | 新建 | admin CPS 端点测试:`/daily` 日期格式 + `/day-users` 聚合与边界 | +| `src/app/(main)/cps/groups/[id]/page.tsx`(前端) | 改 | 日期列加宽、明细列、Modal、Tooltip、按用户表 | + +--- + +## Task 1: 后端 `/daily` 的 `date` 改为 `YYYY-MM-DD` + +**Files:** +- Modify: `app/admin/routers/cps.py:472`(`group_daily` 内构行处) +- Test: `tests/test_cps_admin.py`(新建) + +- [ ] **Step 0: 在后端仓建分支** + +先看工作区状态,避免把无关改动带入: + +Run: +``` +git -C e:\project\shaguabijia-app-server status --short +git -C e:\project\shaguabijia-app-server checkout -b feat/cps-day-user-drilldown +``` +Expected: 切到新分支 `feat/cps-day-user-drilldown`。 + +- [ ] **Step 1: 写失败测试(新建测试文件,含共用 fixtures + 种子)** + +新建 `tests/test_cps_admin.py`,内容如下(fixtures 仿 `tests/test_admin_read.py` 自包含;种子函数后续 Task 复用): + +```python +"""admin CPS 端点测试:每日明细 date 格式 + 按天按用户领券下钻(/day-users)。""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +import pytest +from fastapi.testclient import TestClient + +from app.admin.main import admin_app +from app.admin.repositories import admin_user as admin_repo +from app.admin.repositories import cps as cps_repo +from app.db.session import SessionLocal +from app.models.cps_link import CpsClick +from app.models.cps_wx_user import CpsWxUser +from app.repositories import cps_link as cps_link_repo + +_BJ = timezone(timedelta(hours=8)) + + +@pytest.fixture() +def admin_client() -> TestClient: + return TestClient(admin_app) + + +@pytest.fixture() +def admin_token() -> str: + db = SessionLocal() + try: + if admin_repo.get_by_username(db, "cps_test_admin") is None: + admin_repo.create_admin( + db, username="cps_test_admin", password="cps-pass", role="super_admin" + ) + finally: + db.close() + c = TestClient(admin_app) + r = c.post("/admin/api/auth/login", json={"username": "cps_test_admin", "password": "cps-pass"}) + return r.json()["access_token"] + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _seed_cps_day(day_bj: date) -> tuple[int, str]: + """造淘宝群 + 2 个活动(券) + 2 条 link + day_bj 当天点击(含匿名 + 次日各 1) + 授权画像。 + + o_user(本群唯一):券A visit×3、券B visit×2、copy×2 → visit_count=5、copy_count=2。 + 另造 1 条匿名 visit(openid=None)与 1 条次日 visit,均不应计入当天该用户聚合。 + clicked_at 统一存 tz-aware UTC(SQLite DateTime 按字段渲染、忽略 tzinfo; + 转 UTC 后字段即 UTC 墙钟,与 repo 侧 _as_utc 比较口径一致)。返回 (group_id, openid)。 + """ + db = SessionLocal() + try: + gid = cps_repo.create_group(db, name="下钻测试群", platforms=["taobao"]).id + openid = f"od_user_{gid}" # 按群唯一,规避 CpsWxUser.openid 唯一约束跨用例冲突 + a1 = cps_repo.create_activity(db, name="618神券", platform="taobao", payload="tkl-1") + a2 = cps_repo.create_activity(db, name="买一送一", platform="taobao", payload="tkl-2") + link1 = cps_link_repo.create_link( + db, group_id=gid, activity_id=a1.id, sid=None, target_url="t1", platform="taobao" + ) + link2 = cps_link_repo.create_link( + db, group_id=gid, activity_id=a2.id, sid=None, target_url="t2", platform="taobao" + ) + db.add(CpsWxUser(openid=openid, nickname="张三", headimgurl="https://h/1")) + db.commit() + + def at(hour: int) -> datetime: + return datetime(day_bj.year, day_bj.month, day_bj.day, hour, tzinfo=_BJ).astimezone( + timezone.utc + ) + + for _ in range(3): + db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None, + event_type="visit", openid=openid, clicked_at=at(10))) + for _ in range(2): + db.add(CpsClick(link_id=link2.id, group_id=gid, sid=None, + event_type="visit", openid=openid, clicked_at=at(11))) + for _ in range(2): + db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None, + event_type="copy", openid=openid, clicked_at=at(12))) + # 匿名点击 — 不计入 + db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None, + event_type="visit", openid=None, clicked_at=at(13))) + # 次日点击 — 验证时间窗,不计入当天 + nxt = (datetime(day_bj.year, day_bj.month, day_bj.day, 10, tzinfo=_BJ) + + timedelta(days=1)).astimezone(timezone.utc) + db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None, + event_type="visit", openid=openid, clicked_at=nxt)) + db.commit() + return gid, openid + finally: + db.close() + + +def test_daily_date_is_full_iso(admin_client: TestClient, admin_token: str) -> None: + """/daily 每行 date 改为 YYYY-MM-DD(10 字符、两个连字符),不再是 MM-DD。""" + gid, _ = _seed_cps_day(date(2026, 6, 25)) + r = admin_client.get( + f"/admin/api/cps/groups/{gid}/daily", params={"days": 3}, headers=_auth(admin_token) + ) + assert r.status_code == 200, r.text + rows = r.json()["rows"] + assert rows, "应有按天补零行" + for row in rows: + assert len(row["date"]) == 10 and row["date"].count("-") == 2, row["date"] +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run(从后端仓根目录 `e:\project\shaguabijia-app-server`): +``` +.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_daily_date_is_full_iso -v +``` +Expected: FAIL —— `date` 当前为 `"%m-%d"`(长度 5),断言 `len==10` 不通过。 + +- [ ] **Step 3: 改 router 的日期格式** + +`app/admin/routers/cps.py` 的 `group_daily` 内(约 472 行),把构行的日期字段改为全日期: + +```python + row = { + "date": cur.strftime("%Y-%m-%d"), + "click_pv": cp["click_pv"] if cp else 0, + "click_uv": cp["click_uv"] if cp else 0, + "copy_pv": cp["copy_pv"] if cp else 0, + } +``` + +(仅把原 `cur.strftime("%m-%d")` 改成 `cur.strftime("%Y-%m-%d")`,其余不动。) + +- [ ] **Step 4: 跑测试确认通过** + +Run: +``` +.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_daily_date_is_full_iso -v +``` +Expected: PASS。 + +- [ ] **Step 5: 提交(后端仓)** + +``` +git -C e:\project\shaguabijia-app-server add app/admin/routers/cps.py tests/test_cps_admin.py +git -C e:\project\shaguabijia-app-server commit -m "feat(cps): /daily 的 date 改为 YYYY-MM-DD(下钻定位整天用)" +``` + +--- + +## Task 2: 后端新增 `group_day_users` 聚合 + `/day-users` 接口 + +**Files:** +- Modify: `app/admin/repositories/cps.py:20`(import 补 `CpsLink`)、文件末尾加函数 +- Modify: `app/admin/routers/cps.py`(`group_wx_users` 路由后追加新路由) +- Test: `tests/test_cps_admin.py`(追加) + +- [ ] **Step 1: 写失败测试(聚合 happy path,含匿名/跨天排除、券倒序、合计自洽)** + +在 `tests/test_cps_admin.py` 末尾追加: + +```python +def test_day_users_aggregates(admin_client: TestClient, admin_token: str) -> None: + """当天该群:仅授权用户;领券=copy、点击=visit;coupons=visit 券×次数倒序、合计=点击次数。""" + gid, openid = _seed_cps_day(date(2026, 6, 25)) + r = admin_client.get( + f"/admin/api/cps/groups/{gid}/day-users", + params={"date": "2026-06-25"}, + headers=_auth(admin_token), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["group_id"] == gid + assert body["date"] == "2026-06-25" + users = body["users"] + assert len(users) == 1 # 匿名不计、次日不计 + u = users[0] + assert u["openid"] == openid + assert u["nickname"] == "张三" + assert u["headimgurl"] == "https://h/1" + assert u["copy_count"] == 2 + assert u["visit_count"] == 5 + assert [c["name"] for c in u["coupons"]] == ["618神券", "买一送一"] + assert [c["count"] for c in u["coupons"]] == [3, 2] + assert sum(c["count"] for c in u["coupons"]) == u["visit_count"] +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: +``` +.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_day_users_aggregates -v +``` +Expected: FAIL —— 404/路由不存在(`/day-users` 尚未实现)。 + +- [ ] **Step 3: 补 repo import** + +`app/admin/repositories/cps.py` 第 20 行,给 `cps_link` 的 import 补上 `CpsLink`: + +```python +from app.models.cps_link import CpsClick, CpsLink +``` + +- [ ] **Step 4: 加 repo 聚合函数** + +在 `app/admin/repositories/cps.py` 末尾(`group_wx_users` 之后)追加: + +```python +def group_day_users( + db: Session, *, group_id: int, start: datetime, end: datetime, limit: int = 200, +) -> list[dict]: + """该群某天(北京)以用户为单位的领券/点击 + 每人 visit 过的券。 + + 时间窗为半开区间 [start, end)(end=次日 00:00),避免午夜双计。只统计 openid 非空 + (可归属到人)的点击 —— 匿名点击(美团/京东 302 多为匿名)不计入。券名 = 该点击 link + 对应活动名;活动被硬删则兜底 活动#{id}。copy=领券次数、visit=点击次数;coupons 仅 + 取 visit 事件按活动分组、按次数倒序(合计 = visit_count)。排序:领券 desc、再点击 desc。 + 与 group_wx_users 同风格(Python 侧聚合,跨 PG/SQLite 无方言坑)。 + + 注:每日明细行的 click_pv/copy_pv 计全部点击(含匿名、UV 按 ip,ua);本函数只计 openid + 用户,故各用户求和 <= 当天行总数,二者口径不同、不必相等。 + """ + rows = db.execute( + select(CpsClick.openid, CpsClick.event_type, CpsClick.link_id) + .where(CpsClick.group_id == group_id) + .where(CpsClick.clicked_at >= _as_utc(start)) + .where(CpsClick.clicked_at < _as_utc(end)) + .where(CpsClick.openid.is_not(None)) + ).all() + if not rows: + return [] + + # link_id -> activity_id -> 券名(活动名) + link_ids = {r.link_id for r in rows} + link_to_act = dict( + db.execute( + select(CpsLink.id, CpsLink.activity_id).where(CpsLink.id.in_(link_ids)) + ).all() + ) + act_ids = {aid for aid in link_to_act.values() if aid is not None} + act_name = ( + dict( + db.execute( + select(CpsActivity.id, CpsActivity.name).where(CpsActivity.id.in_(act_ids)) + ).all() + ) + if act_ids + else {} + ) + + def _coupon_name(link_id: int) -> str: + aid = link_to_act.get(link_id) + if aid is None: + return f"链接#{link_id}" + return act_name.get(aid) or f"活动#{aid}" + + stat: dict[str, dict] = {} + for openid, event_type, link_id in rows: + s = stat.setdefault(openid, {"copy": 0, "visit": 0, "coupons": {}}) + if event_type == "copy": + s["copy"] += 1 + else: + s["visit"] += 1 + name = _coupon_name(link_id) + s["coupons"][name] = s["coupons"].get(name, 0) + 1 + + openids = list(stat.keys()) + users = { + u.openid: u + for u in db.execute( + select(CpsWxUser).where(CpsWxUser.openid.in_(openids)) + ).scalars().all() + } + + result = [ + { + "openid": openid, + "nickname": users[openid].nickname if openid in users else None, + "headimgurl": users[openid].headimgurl if openid in users else None, + "copy_count": s["copy"], + "visit_count": s["visit"], + "coupons": [ + {"name": name, "count": cnt} + for name, cnt in sorted( + s["coupons"].items(), key=lambda kv: kv[1], reverse=True + ) + ], + } + for openid, s in stat.items() + ] + result.sort(key=lambda x: (x["copy_count"], x["visit_count"]), reverse=True) + return result[:limit] +``` + +- [ ] **Step 5: 加 router 端点** + +`app/admin/routers/cps.py`,在 `group_wx_users` 路由(文件末尾那个)之后追加: + +```python +@router.get("/groups/{group_id}/day-users", summary="某天该群按用户的领券/点击 + 每人点过的券") +def group_day_users( + group_id: int, + db: AdminDb, + date: Annotated[str, Query(description="北京日期 YYYY-MM-DD")], +) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + bj = timezone(timedelta(hours=8)) + try: + day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj) + except ValueError as e: + raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e + start = day0.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) + users = cps_repo.group_day_users(db, group_id=group_id, start=start, end=end) + return { + "group_id": group.id, + "group_name": group.name, + "date": date, + "users": users, + } +``` + +(`datetime`/`timedelta`/`timezone`、`Annotated`、`Query`、`HTTPException` 该文件均已 import;无需新增。) + +- [ ] **Step 6: 跑测试确认通过** + +Run: +``` +.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_day_users_aggregates -v +``` +Expected: PASS。 + +- [ ] **Step 7: 提交(后端仓)** + +``` +git -C e:\project\shaguabijia-app-server add app/admin/repositories/cps.py app/admin/routers/cps.py tests/test_cps_admin.py +git -C e:\project\shaguabijia-app-server commit -m "feat(cps): 新增 /day-users 按天按用户领券下钻接口" +``` + +--- + +## Task 3: 后端 `/day-users` 边界用例(404 / 400 / 401 / 空天 / 跨年) + +**Files:** +- Test: `tests/test_cps_admin.py`(追加) + +> 这些断言验证 Task 2 实现已覆盖的边界。多数应直接通过;若某条失败,回到 Task 2 对应分支修实现。 + +- [ ] **Step 1: 追加边界测试** + +在 `tests/test_cps_admin.py` 末尾追加: + +```python +def test_day_users_group_not_found(admin_client: TestClient, admin_token: str) -> None: + r = admin_client.get( + "/admin/api/cps/groups/999999/day-users", + params={"date": "2026-06-25"}, + headers=_auth(admin_token), + ) + assert r.status_code == 404 + + +def test_day_users_bad_date(admin_client: TestClient, admin_token: str) -> None: + gid, _ = _seed_cps_day(date(2026, 6, 25)) + r = admin_client.get( + f"/admin/api/cps/groups/{gid}/day-users", + params={"date": "2026/06/25"}, # 非 YYYY-MM-DD + headers=_auth(admin_token), + ) + assert r.status_code == 400 + + +def test_day_users_requires_auth(admin_client: TestClient) -> None: + r = admin_client.get( + "/admin/api/cps/groups/1/day-users", params={"date": "2026-06-25"} + ) + assert r.status_code == 401 + + +def test_day_users_empty_when_no_clicks(admin_client: TestClient, admin_token: str) -> None: + gid, _ = _seed_cps_day(date(2026, 6, 25)) + r = admin_client.get( + f"/admin/api/cps/groups/{gid}/day-users", + params={"date": "2026-06-20"}, # 该群当天无任何点击 + headers=_auth(admin_token), + ) + assert r.status_code == 200 + assert r.json()["users"] == [] + + +def test_day_users_cross_year(admin_client: TestClient, admin_token: str) -> None: + """跨年:YYYY-MM-DD 才能精确定位 12-31(MM-DD 会丢年份);次日(次年 01-01)不计入。""" + gid, openid = _seed_cps_day(date(2025, 12, 31)) + r = admin_client.get( + f"/admin/api/cps/groups/{gid}/day-users", + params={"date": "2025-12-31"}, + headers=_auth(admin_token), + ) + assert r.status_code == 200, r.text + users = r.json()["users"] + assert len(users) == 1 + assert users[0]["openid"] == openid + assert users[0]["visit_count"] == 5 # 次年 01-01 那条被时间窗排除 +``` + +- [ ] **Step 2: 跑整个测试文件确认全绿** + +Run: +``` +.\.venv\Scripts\pytest.exe tests\test_cps_admin.py -v +``` +Expected: 全部 PASS(共 7 个用例:daily 日期、aggregates、404、bad_date、auth、empty、cross_year)。 + +- [ ] **Step 3: 跑后端全量回归(确认没碰坏既有)** + +Run: +``` +.\.venv\Scripts\pytest.exe -q +``` +Expected: 全绿(与改动前同样的通过数 + 新增 7 条)。 + +- [ ] **Step 4: 提交(后端仓)** + +``` +git -C e:\project\shaguabijia-app-server add tests/test_cps_admin.py +git -C e:\project\shaguabijia-app-server commit -m "test(cps): /day-users 边界用例(404/400/401/空天/跨年)" +``` + +--- + +## Task 4: 前端日期列显示全日期并加宽 + +**Files:** +- Modify: `src/app/(main)/cps/groups/[id]/page.tsx` + +前端无测试框架,门槛为类型/构建通过(`npm run build`)。 + +- [ ] **Step 1: 加宽日期列** + +`dailyColumns` 第一列(约 174 行)`width: 64` → `width: 96`: + +```tsx + { title: '日期', dataIndex: 'date', width: 96, fixed: 'left' }, +``` + +(`date` 现由后端给全 `YYYY-MM-DD`,直接显示即可;`DailyRow.date` 类型仍是 `string`,无需改接口。) + +- [ ] **Step 2: 构建确认通过** + +Run(前端仓根目录): +``` +npm run build +``` +Expected: 构建成功,无类型错误。 + +- [ ] **Step 3: 提交(前端仓)** + +``` +git add src/app/(main)/cps/groups/[id]/page.tsx +git commit -m "feat(cps): 每日明细日期列改显示全日期并加宽" +``` + +--- + +## Task 5: 前端下钻 — 明细列 + Modal + Tooltip + +**Files:** +- Modify: `src/app/(main)/cps/groups/[id]/page.tsx` + +- [ ] **Step 1: antd 引入 Modal、Tooltip** + +第 9 行 import 增加 `Modal, Tooltip`(保持字母序即可): + +```tsx +import { Button, Card, Col, Empty, Modal, Row, Segmented, Space, Spin, Statistic, Table, Tooltip, message } from 'antd'; +``` + +- [ ] **Step 2: 加按天用户类型** + +在 `WxUser` 接口定义之后(约 58 行后)追加: + +```tsx +// 某天该群按用户领券下钻(/day-users) +interface DayCoupon { + name: string; + count: number; +} +interface DayUser { + openid: string; + nickname: string | null; + headimgurl: string | null; + copy_count: number; + visit_count: number; + coupons: DayCoupon[]; +} +``` + +- [ ] **Step 3: 加弹窗状态 + 拉取 handler** + +在组件内 `wxUsers` 相关 `useEffect` 之后(约 140 行后)追加。注意须在 `dailyColumns` 定义之前,供其按钮引用: + +```tsx + // 某天领券下钻弹窗 + const [dayDetail, setDayDetail] = useState<{ + open: boolean; + date: string; + loading: boolean; + users: DayUser[]; + }>({ open: false, date: '', loading: false, users: [] }); + + const openDayDetail = useCallback( + async (date: string) => { + setDayDetail({ open: true, date, loading: true, users: [] }); + try { + const r = await api.get<{ users: DayUser[] }>( + `/admin/api/cps/groups/${id}/day-users?date=${date}`, + ); + setDayDetail((s) => ({ ...s, loading: false, users: r.data.users || [] })); + } catch (e) { + message.error(errMsg(e, '明细加载失败')); + setDayDetail((s) => ({ ...s, loading: false })); + } + }, + [id], + ); +``` + +- [ ] **Step 4: 每日明细表加「明细」列 + 调大 scroll.x** + +在 `dailyColumns` 数组末尾(`结算佣金` 列之后,约 226 行)追加一列: + +```tsx + { + title: '明细', + key: 'drill', + width: 64, + fixed: 'right', + render: (_, r) => ( + + ), + }, +``` + +同时,每日明细 ``(约 304–311 行)列宽合计已升至约 976(日期 96 + 新明细列 64),把其 `scroll={{ x: 900 }}` 改为 `scroll={{ x: 1000 }}`,保证右侧固定列与横向滚动正常: + +```tsx + scroll={{ x: 1000 }} +``` + +- [ ] **Step 5: 加按用户表列定义** + +在 `wxColumns` 定义之后(约 259 行后)追加 `dayUserColumns`: + +```tsx + const dayUserColumns: ColumnsType = [ + { + title: '用户', + key: 'user', + render: (_, u) => ( + + {u.headimgurl ? ( + + ) : ( + + )} + {u.nickname || '(未授权昵称)'} + + ), + }, + { + title: '领券次数', + dataIndex: 'copy_count', + width: 100, + sorter: (a, b) => a.copy_count - b.copy_count, + render: (v: number) => (v ? {v} : 0), + }, + { + title: '点击次数', + dataIndex: 'visit_count', + width: 100, + render: (v: number, u) => + u.coupons.length ? ( + + {u.coupons.map((c) => ( +
+ {c.name} ×{c.count} +
+ ))} + + } + > + {v} +
+ ) : ( + v + ), + }, + ]; +``` + +- [ ] **Step 6: 渲染 Modal** + +在最外层 ``(return 末尾,约 329 行)之前、群内微信用户 `Card` 之后,追加: + +```tsx + setDayDetail((s) => ({ ...s, open: false }))} + > +
+ 仅统计微信授权(openid)用户;美团/京东多为匿名跳转,可能为空。悬停「点击次数」看该用户当天点过的券。 +
+ + rowKey="openid" + size="small" + loading={dayDetail.loading} + columns={dayUserColumns} + dataSource={dayDetail.users} + pagination={false} + locale={{ emptyText: '当天无授权用户领券/点击' }} + /> +
+``` + +- [ ] **Step 7: 构建确认通过** + +Run: +``` +npm run build +``` +Expected: 构建成功,无类型错误。 + +- [ ] **Step 8: 提交(前端仓)** + +``` +git add src/app/(main)/cps/groups/[id]/page.tsx +git commit -m "feat(cps): 每日明细下钻弹窗(按用户领券/点击 + 券明细 tooltip)" +``` + +--- + +## Task 6: 全栈联调(手动验证) + +**前置:** 两仓本地起服务。后端 admin API(8771)+ 前端(3001)。可用前端仓 `bash start.sh`(拉起 8770/8771/3001),默认登录 `admin` / `admin12345`。 + +- [ ] **Step 1: 选一个有授权点击的淘宝群** + +后端起来后,直接验证接口(用上面手动登录拿的 admin token,或浏览器已登录的会话): + +Run(确认接口连通,期望 200 + JSON 结构): +``` +.\.venv\Scripts\pytest.exe tests\test_cps_admin.py -v +``` +(接口逻辑已由自动化测试覆盖;本步主要确认前端联调环境。) + +- [ ] **Step 2: 前端页面手动核对** + +打开 `http://localhost:3001` → CPS 分发 → 进任一群详情 → 每日明细表: +- [ ] 日期列完整显示 `YYYY-MM-DD`,列宽不挤。 +- [ ] 每行末尾有「明细」按钮(右侧固定列)。 +- [ ] 点「明细」→ 弹窗打开、标题含群名与日期、加载态正常。 +- [ ] 有数据的淘宝群:用户行显示头像/昵称、领券次数、点击次数;悬停「点击次数」浮出 `券名 ×次数`,各次数之和 = 点击次数。 +- [ ] 昵称为空的用户显示 `(未授权昵称)`。 +- [ ] 美团/京东群或无授权用户的天:弹窗走空态文案 `当天无授权用户领券/点击`。 + +- [ ] **Step 3: 收尾** + +确认前后端各自分支提交完整: +``` +git -C e:\project\shaguabijia-app-server log --oneline -3 +git log --oneline -5 +``` + +--- + +## Self-Review(计划自检结论) + +- **Spec 覆盖:** `/day-users` 接口(Task 2)、券明细 visit×次数倒序(Task 2 test + 前端 Step 5)、`/daily` date 改全日期(Task 1)、匿名排除(Task 2 实现 + Task 2/3 test)、404/400/401(Task 3)、跨年(Task 3)、前端明细列/Modal/Tooltip/日期列加宽(Task 4–5)、不动数据库(无 migration 任务)—— 均有对应任务。 +- **类型一致:** 后端 `group_day_users(db, *, group_id, start, end, limit)` 在 repo 定义、router 按此调用;响应键 `group_id/group_name/date/users[*].{openid,nickname,headimgurl,copy_count,visit_count,coupons[*].{name,count}}` 与前端 `DayUser/DayCoupon` 接口逐字对应。 +- **无占位:** 所有步骤含可直接落地的完整代码与确切命令。 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..e146cd9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-cps-day-user-drilldown-design.md @@ -0,0 +1,132 @@ +# 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. 新增 ``(声明式组件,不受 antd5 静态方法 context 限制,无需 `App.useApp()`)含 `
`,三列: + - **用户**:头像 + 昵称(空 → `(未授权昵称)`),与既有「群内微信用户」表的用户单元一致(头像直接 ``,微信 URL 为绝对地址,非 `/media`)。 + - **领券次数**:`copy_count`,沿用既有红色加粗样式(0 显示 0)。 + - **点击次数**:`visit_count`,整格用 `` 包裹,`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)。 diff --git a/src/app/(main)/cps/groups/[id]/page.tsx b/src/app/(main)/cps/groups/[id]/page.tsx index 7c616e9..4a1b752 100644 --- a/src/app/(main)/cps/groups/[id]/page.tsx +++ b/src/app/(main)/cps/groups/[id]/page.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import dynamic from 'next/dynamic'; import type { ColumnsType } from 'antd/es/table'; -import { Button, Card, Col, Empty, Row, Segmented, Space, Spin, Statistic, Table, message } from 'antd'; +import { Button, Card, Col, Empty, Modal, Row, Segmented, Space, Spin, Statistic, Table, Tooltip, message } from 'antd'; import { api, errMsg } from '@/lib/api'; import { formatUtcTime, yuan } from '@/lib/format'; @@ -57,6 +57,20 @@ interface WxUser { copy_count: number; } +// 某天该群按用户领券下钻(/day-users) +interface DayCoupon { + name: string; + count: number; +} +interface DayUser { + openid: string; + nickname: string | null; + headimgurl: string | null; + copy_count: number; + visit_count: number; + coupons: DayCoupon[]; +} + const RANGES = [ { label: '近 3 天', value: 'd3' }, { label: '近 7 天', value: 'd7' }, @@ -139,6 +153,30 @@ export default function GroupDetailPage() { loadWxUsers(); }, [loadWxUsers]); + // 某天领券下钻弹窗 + const [dayDetail, setDayDetail] = useState<{ + open: boolean; + date: string; + loading: boolean; + users: DayUser[]; + }>({ open: false, date: '', loading: false, users: [] }); + + const openDayDetail = useCallback( + async (date: string) => { + setDayDetail({ open: true, date, loading: true, users: [] }); + try { + const r = await api.get<{ users: DayUser[] }>( + `/admin/api/cps/groups/${id}/day-users?date=${date}`, + ); + setDayDetail((s) => ({ ...s, loading: false, users: r.data.users || [] })); + } catch (e) { + message.error(errMsg(e, '明细加载失败')); + setDayDetail((s) => ({ ...s, loading: false })); + } + }, + [id], + ); + const points = useMemo(() => data?.points ?? [], [data]); // 展平成「长表」给折线图:每个 (时间桶 × 指标) 一行,colorField 按指标分 4 条线。 const chartData = useMemo( @@ -171,7 +209,7 @@ export default function GroupDetailPage() { }; const dailyColumns: ColumnsType = [ - { title: '日期', dataIndex: 'date', width: 64, fixed: 'left' }, + { title: '日期', dataIndex: 'date', width: 96, fixed: 'left' }, { title: '点击', dataIndex: 'click_pv', width: 56 }, { title: '独立', dataIndex: 'click_uv', width: 56 }, { title: '复制', dataIndex: 'copy_pv', width: 56, render: (v: number) => v || '-' }, @@ -224,6 +262,17 @@ export default function GroupDetailPage() { width: 84, render: (v: number | null) => (v == null ? '-' : {yuan(v)}), }, + { + title: '明细', + key: 'drill', + width: 64, + fixed: 'right', + render: (_, r) => ( + + ), + }, ]; const wxColumns: ColumnsType = [ @@ -258,6 +307,59 @@ export default function GroupDetailPage() { { title: '首次进入', dataIndex: 'first_seen', width: 160, render: (v: string) => formatUtcTime(v) }, ]; + const dayUserColumns: ColumnsType = [ + { + title: '用户', + key: 'user', + render: (_, u) => ( + + {u.headimgurl ? ( + + ) : ( + + )} + {u.nickname || '(未授权昵称)'} + + ), + }, + { + title: '领券次数', + dataIndex: 'copy_count', + width: 100, + sorter: (a, b) => a.copy_count - b.copy_count, + render: (v: number) => (v ? {v} : 0), + }, + { + title: '点击次数', + dataIndex: 'visit_count', + width: 100, + render: (v: number, u) => + u.coupons.length ? ( + + {u.coupons.map((c) => ( +
+ {c.name} ×{c.count} +
+ ))} + + } + > + {v} +
+ ) : ( + v + ), + }, + ]; + return (
@@ -307,7 +409,7 @@ export default function GroupDetailPage() { columns={dailyColumns} dataSource={daily?.rows ?? []} pagination={false} - scroll={{ x: 900 }} + scroll={{ x: 1000 }} /> )} @@ -326,6 +428,28 @@ export default function GroupDetailPage() { locale={{ emptyText: '暂无授权用户' }} /> + + setDayDetail((s) => ({ ...s, open: false }))} + > +
+ 仅统计微信授权(openid)用户;美团/京东多为匿名跳转,可能为空。悬停「点击次数」看该用户当天点过的券。 +
+ + rowKey="openid" + size="small" + loading={dayDetail.loading} + columns={dayUserColumns} + dataSource={dayDetail.users} + pagination={false} + locale={{ emptyText: '当天无授权用户领券/点击' }} + /> +
); }