feat(cps): 每日明细按天按用户领券下钻接口 + /daily date 改全日期 (#79)
- 新增 GET /admin/api/cps/groups/{id}/day-users?date=YYYY-MM-DD:
按 openid 聚合当天领券(copy)/点击(visit) + 每人 visit 过的券(券×次数)
---------
Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #79
This commit was merged in pull request #79.
This commit is contained in:
@@ -17,7 +17,7 @@ from app.integrations import meituan
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_group import CpsGroup
|
||||
from app.models.cps_link import CpsClick
|
||||
from app.models.cps_link import CpsClick, CpsLink
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
|
||||
@@ -524,3 +524,90 @@ def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict
|
||||
]
|
||||
result.sort(key=lambda x: x["first_seen"], reverse=True)
|
||||
return result[:limit]
|
||||
|
||||
|
||||
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}
|
||||
# 次数倒序;同次数按券名升序兜底,保证 PG 无 ORDER BY 行序下输出稳定
|
||||
for name, cnt in sorted(
|
||||
s["coupons"].items(), key=lambda kv: (-kv[1], kv[0])
|
||||
)
|
||||
],
|
||||
}
|
||||
for openid, s in stat.items()
|
||||
]
|
||||
result.sort(key=lambda x: (x["copy_count"], x["visit_count"]), reverse=True)
|
||||
return result[:limit]
|
||||
|
||||
@@ -468,7 +468,7 @@ def group_daily(
|
||||
while cur <= last:
|
||||
cp = click_points[idx] if idx < len(click_points) else None
|
||||
row = {
|
||||
"date": cur.strftime("%m-%d"),
|
||||
"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,
|
||||
@@ -496,3 +496,28 @@ def group_wx_users(group_id: int, db: AdminDb) -> dict:
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""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"]
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
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 那条被时间窗排除
|
||||
Reference in New Issue
Block a user