577 lines
23 KiB
Python
577 lines
23 KiB
Python
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
|
|
|
|
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
|
|
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
|
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
|
|
|
from app.admin.audit import write_audit
|
|
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
|
from app.admin.repositories import cps as cps_repo
|
|
from app.admin.schemas.common import CursorPage
|
|
from app.admin.schemas.cps import (
|
|
CpsActivityCreate,
|
|
CpsActivityOut,
|
|
CpsActivityUpdate,
|
|
CpsGroupCreate,
|
|
CpsGroupOut,
|
|
CpsGroupStat,
|
|
CpsGroupUpdate,
|
|
CpsOrderOut,
|
|
CpsReconcileResult,
|
|
CpsReferralLinkItem,
|
|
CpsReferralLinksOut,
|
|
CpsReferralLinksRequest,
|
|
CpsStatsOut,
|
|
)
|
|
from app.core import media
|
|
from app.core.config import settings
|
|
from app.integrations import meituan
|
|
from app.integrations.meituan import MeituanCpsError
|
|
from app.models.admin import AdminUser
|
|
from app.models.cps_activity import CpsActivity
|
|
from app.models.cps_group import CpsGroup
|
|
from app.repositories import cps_link as cps_link_repo
|
|
|
|
router = APIRouter(
|
|
prefix="/admin/api/cps",
|
|
tags=["admin-cps"],
|
|
dependencies=[Depends(get_current_admin)],
|
|
)
|
|
|
|
_VALID_PLATFORMS = {"meituan", "taobao", "jd"}
|
|
|
|
|
|
# ───────────── 群 ─────────────
|
|
@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表")
|
|
def list_groups(
|
|
db: AdminDb,
|
|
keyword: Annotated[str | None, Query(max_length=100)] = None,
|
|
status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None,
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
cursor: Annotated[int | None, Query()] = None,
|
|
) -> CursorPage[CpsGroupOut]:
|
|
items, next_cursor, total = cps_repo.list_groups(
|
|
db, keyword=keyword, status=status, limit=limit, cursor=cursor
|
|
)
|
|
return CursorPage(
|
|
items=[CpsGroupOut.model_validate(g) for g in items],
|
|
next_cursor=next_cursor,
|
|
total=total,
|
|
)
|
|
|
|
|
|
@router.post("/groups", response_model=CpsGroupOut, summary="新建群")
|
|
def create_group(
|
|
body: CpsGroupCreate,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> CpsGroupOut:
|
|
bad = [p for p in body.platforms if p not in _VALID_PLATFORMS]
|
|
if bad:
|
|
raise HTTPException(status_code=400, detail=f"非法平台: {bad}")
|
|
has_meituan = "meituan" in body.platforms
|
|
if has_meituan and body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None:
|
|
raise HTTPException(status_code=409, detail="sid 已存在")
|
|
group = cps_repo.create_group(
|
|
db, name=body.name, platforms=body.platforms, sid=body.sid,
|
|
member_count=body.member_count, remark=body.remark, commit=False,
|
|
)
|
|
write_audit(
|
|
db, admin, action="cps.group.create", target_type="cps_group", target_id=group.id,
|
|
detail={"name": group.name, "platforms": group.platforms, "sid": group.sid},
|
|
ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
db.refresh(group)
|
|
return CpsGroupOut.model_validate(group)
|
|
|
|
|
|
@router.patch("/groups/{group_id}", response_model=CpsGroupOut, summary="编辑群")
|
|
def update_group(
|
|
group_id: int,
|
|
body: CpsGroupUpdate,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> CpsGroupOut:
|
|
group = cps_repo.get_group(db, group_id)
|
|
if group is None:
|
|
raise HTTPException(status_code=404, detail="群不存在")
|
|
if body.platforms is not None:
|
|
bad = [p for p in body.platforms if p not in _VALID_PLATFORMS]
|
|
if bad:
|
|
raise HTTPException(status_code=400, detail=f"非法平台: {bad}")
|
|
cps_repo.update_group(
|
|
db, group, name=body.name, platforms=body.platforms,
|
|
member_count=body.member_count, status=body.status, remark=body.remark, commit=False,
|
|
)
|
|
write_audit(
|
|
db, admin, action="cps.group.update", target_type="cps_group", target_id=group_id,
|
|
detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
db.refresh(group)
|
|
return CpsGroupOut.model_validate(group)
|
|
|
|
|
|
@router.delete("/groups/{group_id}", summary="删除群")
|
|
def delete_group(
|
|
group_id: int,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> dict:
|
|
group = cps_repo.get_group(db, group_id)
|
|
if group is None:
|
|
raise HTTPException(status_code=404, detail="群不存在")
|
|
write_audit(
|
|
db, admin, action="cps.group.delete", target_type="cps_group", target_id=group_id,
|
|
detail={"name": group.name, "sid": group.sid, "platforms": list(group.platforms or [])},
|
|
ip=get_client_ip(request), commit=False,
|
|
)
|
|
cps_repo.delete_group(db, group, commit=False)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ───────────── 活动 ─────────────
|
|
@router.get("/activities", response_model=CursorPage[CpsActivityOut], summary="活动列表")
|
|
def list_activities(
|
|
db: AdminDb,
|
|
platform: Annotated[str | None, Query(max_length=20)] = None,
|
|
status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None,
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
cursor: Annotated[int | None, Query()] = None,
|
|
) -> CursorPage[CpsActivityOut]:
|
|
items, next_cursor, total = cps_repo.list_activities(
|
|
db, platform=platform, status=status, limit=limit, cursor=cursor
|
|
)
|
|
return CursorPage(
|
|
items=[CpsActivityOut.model_validate(a) for a in items],
|
|
next_cursor=next_cursor,
|
|
total=total,
|
|
)
|
|
|
|
|
|
@router.post("/activities", response_model=CpsActivityOut, summary="新建活动")
|
|
def create_activity(
|
|
body: CpsActivityCreate,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> CpsActivityOut:
|
|
if body.platform == "meituan":
|
|
if not body.act_id and not body.product_view_sign:
|
|
raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign")
|
|
else: # taobao / jd
|
|
if not body.payload:
|
|
label = "淘口令" if body.platform == "taobao" else "推广链接"
|
|
raise HTTPException(status_code=400, detail=f"{body.platform} 活动需填{label}")
|
|
if body.platform == "taobao" and not body.image_url:
|
|
raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)")
|
|
activity = cps_repo.create_activity(
|
|
db, name=body.name, platform=body.platform, act_id=body.act_id,
|
|
product_view_sign=body.product_view_sign, payload=body.payload,
|
|
image_url=media.to_abs_media_url(body.image_url), remark=body.remark, commit=False,
|
|
)
|
|
write_audit(
|
|
db, admin, action="cps.activity.create", target_type="cps_activity", target_id=activity.id,
|
|
detail={"name": activity.name, "platform": activity.platform},
|
|
ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
db.refresh(activity)
|
|
return CpsActivityOut.model_validate(activity)
|
|
|
|
|
|
@router.patch("/activities/{activity_id}", response_model=CpsActivityOut, summary="编辑活动")
|
|
def update_activity(
|
|
activity_id: int,
|
|
body: CpsActivityUpdate,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> CpsActivityOut:
|
|
activity = cps_repo.get_activity(db, activity_id)
|
|
if activity is None:
|
|
raise HTTPException(status_code=404, detail="活动不存在")
|
|
# 用「合并后最终值」校验平台必填项(同新建口径),允许只改部分字段
|
|
platform = body.platform or activity.platform
|
|
act_id = body.act_id if body.act_id is not None else activity.act_id
|
|
pvs = body.product_view_sign if body.product_view_sign is not None else activity.product_view_sign
|
|
payload = body.payload if body.payload is not None else activity.payload
|
|
image_url = body.image_url if body.image_url is not None else activity.image_url
|
|
if platform == "meituan":
|
|
if not act_id and not pvs:
|
|
raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign")
|
|
else: # taobao / jd
|
|
if not payload:
|
|
label = "淘口令" if platform == "taobao" else "推广链接"
|
|
raise HTTPException(status_code=400, detail=f"{platform} 活动需填{label}")
|
|
if platform == "taobao" and not image_url:
|
|
raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)")
|
|
cps_repo.update_activity(
|
|
db, activity, name=body.name, platform=body.platform, act_id=body.act_id,
|
|
product_view_sign=body.product_view_sign, payload=body.payload,
|
|
image_url=media.to_abs_media_url(body.image_url) if body.image_url else None,
|
|
remark=body.remark, status=body.status, commit=False,
|
|
)
|
|
write_audit(
|
|
db, admin, action="cps.activity.update", target_type="cps_activity", target_id=activity_id,
|
|
detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
db.refresh(activity)
|
|
return CpsActivityOut.model_validate(activity)
|
|
|
|
|
|
@router.post("/upload-image", summary="上传活动落地页图(返回绝对 URL)")
|
|
async def upload_activity_image(
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
file: UploadFile = File(..., description="落地页图(jpeg/png/webp,≤5MB)"),
|
|
) -> dict:
|
|
data = await file.read()
|
|
try:
|
|
url = media.save_cps_image(admin.id, data)
|
|
except media.MediaError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
return {"url": media.to_abs_media_url(url)}
|
|
|
|
|
|
@router.get("/activity-images", summary="已有活动落地页图(供新建复用选择)")
|
|
def activity_images(db: AdminDb) -> dict:
|
|
return {"images": cps_repo.list_activity_images(db)}
|
|
|
|
|
|
@router.delete("/activities/{activity_id}", summary="删除活动")
|
|
def delete_activity(
|
|
activity_id: int,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> dict:
|
|
activity = cps_repo.get_activity(db, activity_id)
|
|
if activity is None:
|
|
raise HTTPException(status_code=404, detail="活动不存在")
|
|
write_audit(
|
|
db, admin, action="cps.activity.delete", target_type="cps_activity", target_id=activity_id,
|
|
detail={"name": activity.name, "platform": activity.platform},
|
|
ip=get_client_ip(request), commit=False,
|
|
)
|
|
cps_repo.delete_activity(db, activity, commit=False)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ───────────── 生成链接(批量,按平台分支) ─────────────
|
|
def _gen_one_link(db, group: CpsGroup, activity: CpsActivity):
|
|
"""给一个活动生成一条落地页 link。美团:转链拿短链(需群 sid);淘宝/京东:用 payload。"""
|
|
if activity.platform == "meituan":
|
|
if not group.sid:
|
|
raise HTTPException(status_code=400, detail=f"群「{group.name}」无 sid,无法生成美团链接")
|
|
try:
|
|
resp = meituan.get_referral_link(
|
|
act_id=activity.act_id, product_view_sign=activity.product_view_sign,
|
|
sid=group.sid, link_type_list=[1, 2, 3],
|
|
)
|
|
except MeituanCpsError as e:
|
|
raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e
|
|
link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()}
|
|
if not link_map and resp.get("data"):
|
|
link_map = {"1": resp["data"]}
|
|
target = link_map.get("2") or link_map.get("1") or link_map.get("3")
|
|
if not target:
|
|
raise HTTPException(status_code=502, detail=f"美团未返回有效链接(活动「{activity.name}」)")
|
|
else: # taobao(淘口令) / jd(链接)
|
|
if not activity.payload:
|
|
raise HTTPException(status_code=400, detail=f"活动「{activity.name}」缺少内容")
|
|
target = activity.payload
|
|
return cps_link_repo.create_link(
|
|
db, group_id=group.id, activity_id=activity.id, sid=group.sid,
|
|
target_url=target, platform=activity.platform, commit=False,
|
|
)
|
|
|
|
|
|
@router.post("/referral-links", response_model=CpsReferralLinksOut, summary="批量生成落地页短链")
|
|
def generate_referral_links(
|
|
body: CpsReferralLinksRequest,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> CpsReferralLinksOut:
|
|
group = cps_repo.get_group(db, body.group_id)
|
|
if group is None:
|
|
raise HTTPException(status_code=404, detail="群不存在")
|
|
group_platforms = set(group.platforms or [])
|
|
base = settings.CPS_REDIRECT_BASE.rstrip("/")
|
|
|
|
items: list[CpsReferralLinkItem] = []
|
|
for aid in body.activity_ids:
|
|
activity = cps_repo.get_activity(db, aid)
|
|
if activity is None:
|
|
raise HTTPException(status_code=404, detail=f"活动 {aid} 不存在")
|
|
if activity.platform not in group_platforms:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"活动「{activity.name}」平台({activity.platform})不在群「{group.name}」范围内",
|
|
)
|
|
link = _gen_one_link(db, group, activity)
|
|
items.append(CpsReferralLinkItem(
|
|
activity_id=activity.id, activity_name=activity.name, platform=activity.platform,
|
|
redirect_url=f"{base}/c/{link.code}" if base else f"/c/{link.code}", code=link.code,
|
|
))
|
|
|
|
write_audit(
|
|
db, admin, action="cps.referral_link.generate", target_type="cps_group", target_id=group.id,
|
|
detail={"group": group.name, "activity_ids": body.activity_ids, "count": len(items)},
|
|
ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
return CpsReferralLinksOut(group_name=group.name, results=items)
|
|
|
|
|
|
# ───────────── 订单对账 ─────────────
|
|
_BEIJING = timezone(timedelta(hours=8))
|
|
|
|
|
|
def _parse_day(value: str | None, *, field: str) -> _date | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return _date.fromisoformat(value)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
|
|
|
|
|
def _reconcile_range_to_ts(
|
|
date_from: _date | None, date_to: _date | None, days: int
|
|
) -> tuple[int, int]:
|
|
if date_from is None and date_to is None:
|
|
now = int(time.time())
|
|
return now - days * 86400, now
|
|
|
|
start_day = date_from or date_to
|
|
end_day = date_to or date_from
|
|
if start_day is None or end_day is None:
|
|
raise HTTPException(status_code=422, detail="日期参数不完整")
|
|
if start_day > end_day:
|
|
start_day, end_day = end_day, start_day
|
|
if (end_day - start_day).days + 1 > 90:
|
|
raise HTTPException(status_code=422, detail="美团订单查询最长 90 天")
|
|
|
|
start_dt = datetime.combine(start_day, _dt_time.min, tzinfo=_BEIJING)
|
|
end_dt = datetime.combine(end_day + timedelta(days=1), _dt_time.min, tzinfo=_BEIJING)
|
|
return int(start_dt.timestamp()), int(end_dt.timestamp())
|
|
|
|
|
|
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
|
|
def reconcile_orders(
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
|
db: AdminDb,
|
|
date_from: Annotated[str | None, Query(description="起始日 YYYY-MM-DD")] = None,
|
|
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
|
|
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
|
|
sid: Annotated[str | None, Query(max_length=64)] = None,
|
|
query_time_type: Annotated[int, Query(ge=1, le=2)] = 1,
|
|
) -> CpsReconcileResult:
|
|
start_ts, end_ts = _reconcile_range_to_ts(
|
|
_parse_day(date_from, field="date_from"),
|
|
_parse_day(date_to, field="date_to"),
|
|
days,
|
|
)
|
|
try:
|
|
result = cps_repo.reconcile_orders(
|
|
db,
|
|
start_time=start_ts,
|
|
end_time=end_ts,
|
|
query_time_type=query_time_type,
|
|
sid=sid,
|
|
)
|
|
except MeituanCpsError as e:
|
|
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
|
|
write_audit(
|
|
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
|
|
detail={
|
|
"date_from": date_from,
|
|
"date_to": date_to,
|
|
"days": days,
|
|
"sid": sid,
|
|
"query_time_type": query_time_type,
|
|
**result,
|
|
},
|
|
ip=get_client_ip(request),
|
|
commit=True,
|
|
)
|
|
return CpsReconcileResult(**result)
|
|
|
|
|
|
@router.get("/orders", response_model=CursorPage[CpsOrderOut], summary="订单明细")
|
|
def list_orders(
|
|
db: AdminDb,
|
|
sid: Annotated[str | None, Query(max_length=64)] = None,
|
|
mt_status: Annotated[str | None, Query(max_length=8)] = None,
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
cursor: Annotated[int | None, Query()] = None,
|
|
) -> CursorPage[CpsOrderOut]:
|
|
items, next_cursor, total = cps_repo.list_orders(
|
|
db, sid=sid, mt_status=mt_status, limit=limit, cursor=cursor
|
|
)
|
|
return CursorPage(
|
|
items=[CpsOrderOut.model_validate(o) for o in items],
|
|
next_cursor=next_cursor,
|
|
total=total,
|
|
)
|
|
|
|
|
|
# ───────────── 统计 ─────────────
|
|
@router.get("/stats", response_model=CpsStatsOut, summary="按群对账统计")
|
|
def get_stats(
|
|
db: AdminDb,
|
|
days: Annotated[int, Query(ge=1, le=90)] = 30,
|
|
) -> CpsStatsOut:
|
|
date_to = datetime.now(timezone.utc)
|
|
date_from = date_to - timedelta(days=days)
|
|
rows = cps_repo.group_stats(db, date_from=date_from, date_to=date_to)
|
|
stats = [CpsGroupStat(**r) for r in rows]
|
|
return CpsStatsOut(
|
|
groups=stats,
|
|
total_order_count=sum(s.order_count or 0 for s in stats),
|
|
total_est_commission_cents=sum(s.est_commission_cents or 0 for s in stats),
|
|
total_settled_commission_cents=sum(s.settled_commission_cents or 0 for s in stats),
|
|
)
|
|
|
|
|
|
@router.get("/groups/{group_id}/timeseries", summary="群点击时序(折线图:天/小时级 PV/UV/复制)")
|
|
def group_timeseries(
|
|
group_id: int,
|
|
db: AdminDb,
|
|
granularity: Annotated[str, Query(pattern="^(day|hour)$")] = "day",
|
|
days: Annotated[int, Query(ge=1, le=30)] = 7,
|
|
date: Annotated[str | None, Query()] = None, # hour 粒度看哪天(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))
|
|
now_bj = datetime.now(bj)
|
|
if granularity == "hour":
|
|
if date:
|
|
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
|
|
else:
|
|
day0 = now_bj
|
|
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
end = start + timedelta(days=1) - timedelta(microseconds=1)
|
|
else:
|
|
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
end = now_bj
|
|
points = cps_repo.group_click_timeseries(
|
|
db, group_id=group_id, granularity=granularity, start=start, end=end
|
|
)
|
|
return {
|
|
"group_id": group.id,
|
|
"group_name": group.name,
|
|
"granularity": granularity,
|
|
"points": points,
|
|
}
|
|
|
|
|
|
@router.get("/groups/{group_id}/daily", summary="群每天明细大表格(点击+订单按天合并)")
|
|
def group_daily(
|
|
group_id: int,
|
|
db: AdminDb,
|
|
days: Annotated[int, Query(ge=1, le=90)] = 7,
|
|
) -> dict:
|
|
group = cps_repo.get_group(db, group_id)
|
|
if group is None:
|
|
raise HTTPException(status_code=404, detail="群不存在")
|
|
bj = timezone(timedelta(hours=8))
|
|
now_bj = datetime.now(bj)
|
|
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
end = now_bj
|
|
# 点击侧:连续 N 天补零,顺序 = start..end(与下面 while 同序,按 index 对齐避免跨年 MM-DD 碰撞)
|
|
click_points = cps_repo.group_click_timeseries(
|
|
db, group_id=group_id, granularity="day", start=start, end=end
|
|
)
|
|
# 订单侧:仅美团群(有 sid)有对账;淘宝/京东订单列返回 None(前端显示 "-")
|
|
is_meituan = "meituan" in (group.platforms or []) and bool(group.sid)
|
|
order_daily = (
|
|
cps_repo.group_order_daily(db, sid=group.sid, start=start, end=end) if is_meituan else {}
|
|
)
|
|
_ORDER_KEYS = (
|
|
"order_count", "canceled_count", "gmv_cents",
|
|
"est_commission_cents", "refund_profit_cents", "settled_commission_cents",
|
|
)
|
|
rows = []
|
|
cur = start.astimezone(bj).date()
|
|
last = end.astimezone(bj).date()
|
|
idx = 0
|
|
while cur <= last:
|
|
cp = click_points[idx] if idx < len(click_points) else None
|
|
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,
|
|
}
|
|
if is_meituan:
|
|
od = order_daily.get(cur.strftime("%Y-%m-%d"))
|
|
row.update({k: (od[k] if od else 0) for k in _ORDER_KEYS})
|
|
else:
|
|
row.update({k: None for k in _ORDER_KEYS})
|
|
rows.append(row)
|
|
idx += 1
|
|
cur += timedelta(days=1)
|
|
return {
|
|
"group_id": group.id,
|
|
"group_name": group.name,
|
|
"is_meituan": is_meituan,
|
|
"days": days,
|
|
"rows": rows,
|
|
}
|
|
|
|
|
|
@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)")
|
|
def group_wx_users(group_id: int, db: AdminDb) -> dict:
|
|
group = cps_repo.get_group(db, group_id)
|
|
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,
|
|
}
|