a3a14b484a
改了什么:新增京东联盟订单查询客户端、cps_order 京东字段迁移、CPS 对账接口 platform=all/jd、数据大盘京东订单与佣金聚合。 为什么改:数据大盘需要展示京东 CPS 真实订单佣金,不再保持占位。 验证方式:python -m pytest tests\\test_admin_read.py::test_dashboard_overview tests\\test_cps_admin.py::test_jd_reconcile_updates_dashboard -q;本地真实拉取 2026-06-27 京东订单 3 行,页面显示京东有效 1 单/佣金 ¥0.13。 --------- Co-authored-by: lowmaster-chen <1119780489@qq.com> Reviewed-on: #90 Co-authored-by: chenshuobo <chenshuobo@wonderable.ai> Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
254 lines
9.9 KiB
Python
254 lines
9.9 KiB
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_order import CpsOrder
|
||
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 那条被时间窗排除
|
||
|
||
|
||
def test_jd_reconcile_updates_dashboard(
|
||
admin_client: TestClient, admin_token: str, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""京东拉单:按订单行入库;大盘只统计有效 validCode 的佣金。"""
|
||
|
||
def fake_query_order_rows(**kwargs):
|
||
start = kwargs["start_time"].astimezone(_BJ)
|
||
if start.hour != 10 or kwargs["page_index"] != 1:
|
||
return {"rows": [], "has_more": False}
|
||
return {
|
||
"rows": [
|
||
{
|
||
"id": "pytest-jd-row-valid",
|
||
"orderId": "pytest-jd-order-1",
|
||
"skuId": "sku-1",
|
||
"skuName": "京东测试商品",
|
||
"orderTime": "2026-06-25 10:10:00",
|
||
"modifyTime": "2026-06-25 10:20:00",
|
||
"validCode": "16",
|
||
"estimateCosPrice": "19.90",
|
||
"estimateFee": "1.23",
|
||
"actualFee": "2.34",
|
||
"commissionRate": "10.00",
|
||
},
|
||
{
|
||
"id": "pytest-jd-row-invalid",
|
||
"orderId": "pytest-jd-order-2",
|
||
"skuId": "sku-2",
|
||
"skuName": "京东无效订单",
|
||
"orderTime": "2026-06-25 10:15:00",
|
||
"modifyTime": "2026-06-25 10:25:00",
|
||
"validCode": "4",
|
||
"estimateCosPrice": "9.90",
|
||
"estimateFee": "0.50",
|
||
"commissionRate": "5.00",
|
||
},
|
||
],
|
||
"has_more": False,
|
||
}
|
||
|
||
monkeypatch.setattr("app.integrations.jd_union.query_order_rows", fake_query_order_rows)
|
||
r = admin_client.post(
|
||
"/admin/api/cps/orders/reconcile",
|
||
params={
|
||
"platform": "jd",
|
||
"date_from": "2026-06-25",
|
||
"date_to": "2026-06-25",
|
||
"query_time_type": 1,
|
||
},
|
||
headers=_auth(admin_token),
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["fetched"] == 2
|
||
|
||
with SessionLocal() as db:
|
||
rows = db.query(CpsOrder).filter(CpsOrder.platform == "jd").all()
|
||
assert len([o for o in rows if o.order_id.startswith("jd:pytest-jd-row")]) == 2
|
||
|
||
overview = admin_client.get(
|
||
"/admin/api/stats/overview",
|
||
params={"date_from": "2026-06-25", "date_to": "2026-06-25"},
|
||
headers=_auth(admin_token),
|
||
)
|
||
assert overview.status_code == 200, overview.text
|
||
cps = overview.json()["cps"]
|
||
assert cps["jd_order_count"] >= 1
|
||
assert cps["jd_commission_cents"] >= 234
|
||
assert cps["jd_invalid_count"] >= 1
|