feat: 接入数据大盘聚合与美团 CPS 对账
改了什么:新增新版大盘日期窗口聚合、广告收益拆分、比价耗时字段、美团 CPS 拉单入库与大盘展示,并同步反馈审核和邀请奖励下线口径。 验证:python -m pytest tests/test_admin_read.py tests/test_admin_write.py tests/test_compare_record.py tests/test_invite.py tests/test_feedback.py -q。
This commit is contained in:
@@ -14,10 +14,13 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.admin.routers.ad_audit import router as ad_audit_router
|
||||
from app.admin.routers.ad_revenue import router as ad_revenue_router
|
||||
from app.admin.routers.admins import router as admins_router
|
||||
from app.admin.routers.audit import router as audit_router
|
||||
from app.admin.routers.auth import router as auth_router
|
||||
from app.admin.routers.comparison import router as comparison_router
|
||||
from app.admin.routers.config import router as config_router
|
||||
from app.admin.routers.cps import router as cps_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
@@ -87,4 +90,7 @@ admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(comparison_router)
|
||||
admin_app.include_router(cps_router)
|
||||
admin_app.include_router(ad_audit_router)
|
||||
admin_app.include_router(ad_revenue_router)
|
||||
|
||||
@@ -141,13 +141,12 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def ad_coin_audit(
|
||||
db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int
|
||||
def audit_rows(
|
||||
db: Session, *, date: str, user_id: int | None, scene: str | None
|
||||
) -> list[dict]:
|
||||
"""返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。
|
||||
"""返回当日发奖复算明细,按 created_at 倒序(最新在前),不截断。
|
||||
|
||||
scene: None=两类都要 / "reward_video" / "feed"。
|
||||
份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
if scene in (None, "reward_video"):
|
||||
@@ -155,6 +154,17 @@ def ad_coin_audit(
|
||||
if scene in (None, "feed"):
|
||||
rows.extend(_feed_rows(db, date=date, user_id=user_id))
|
||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def ad_coin_audit(
|
||||
db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int
|
||||
) -> list[dict]:
|
||||
"""返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。
|
||||
|
||||
份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。
|
||||
"""
|
||||
rows = audit_rows(db, date=date, user_id=user_id, scene=scene)
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""admin 广告收益报表:按日期/用户/广告类型聚合展示收益和发奖金币。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories import ad_audit
|
||||
from app.core import rewards
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(rewards.CN_TZ).hour
|
||||
|
||||
|
||||
def _key(
|
||||
report_date: str,
|
||||
user_id: int,
|
||||
ad_type: str,
|
||||
feed_scene: str | None,
|
||||
app_env: str | None,
|
||||
our_code_id: str | None,
|
||||
hour: int | None,
|
||||
) -> tuple:
|
||||
return (
|
||||
report_date,
|
||||
user_id,
|
||||
ad_type,
|
||||
feed_scene or None,
|
||||
app_env or None,
|
||||
our_code_id or None,
|
||||
hour,
|
||||
)
|
||||
|
||||
|
||||
def _date_range(date_from: str, date_to: str) -> list[str]:
|
||||
d0 = _date.fromisoformat(date_from)
|
||||
d1 = _date.fromisoformat(date_to)
|
||||
out: list[str] = []
|
||||
cur = d0
|
||||
while cur <= d1:
|
||||
out.append(cur.isoformat())
|
||||
cur += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
|
||||
|
||||
|
||||
def ad_revenue_report(
|
||||
db: Session,
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
user_id: int | None = None,
|
||||
ad_type: str | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
by_hour = granularity == "hour"
|
||||
groups: dict[tuple, dict] = {}
|
||||
|
||||
def _grp(key: tuple) -> dict:
|
||||
g = groups.get(key)
|
||||
if g is None:
|
||||
rdate, uid, atype, feed_scene, app_env, code_id, hour = key
|
||||
g = {
|
||||
"report_date": rdate,
|
||||
"user_id": uid,
|
||||
"ad_type": atype,
|
||||
"feed_scene": feed_scene,
|
||||
"app_env": app_env,
|
||||
"our_code_id": code_id,
|
||||
"hour": hour,
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
"adns": set(),
|
||||
"impression_records": [],
|
||||
"records": [],
|
||||
}
|
||||
groups[key] = g
|
||||
return g
|
||||
|
||||
stmt = select(AdEcpmRecord).where(
|
||||
AdEcpmRecord.report_date >= date_from,
|
||||
AdEcpmRecord.report_date <= date_to,
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
|
||||
if ad_type is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
|
||||
for rec in db.execute(stmt).scalars():
|
||||
hour = _cn_hour(rec.created_at) if by_hour else None
|
||||
app_env = getattr(rec, "app_env", None)
|
||||
our_code_id = getattr(rec, "our_code_id", None)
|
||||
feed_scene = getattr(rec, "feed_scene", None)
|
||||
g = _grp(
|
||||
_key(
|
||||
rec.report_date,
|
||||
rec.user_id,
|
||||
rec.ad_type,
|
||||
feed_scene,
|
||||
app_env,
|
||||
our_code_id,
|
||||
hour,
|
||||
)
|
||||
)
|
||||
g["impressions"] += 1
|
||||
rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0
|
||||
g["revenue_yuan"] += rev
|
||||
if rec.adn:
|
||||
g["adns"].add(rec.adn)
|
||||
g["impression_records"].append(
|
||||
{
|
||||
"id": rec.id,
|
||||
"created_at": rec.created_at,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"revenue_yuan": round(rev, 6),
|
||||
"adn": rec.adn,
|
||||
"slot_id": rec.slot_id,
|
||||
}
|
||||
)
|
||||
|
||||
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
|
||||
if ad_type is None or audit_scene is not None:
|
||||
for day in _date_range(date_from, date_to):
|
||||
for row in ad_audit.audit_rows(db, date=day, user_id=user_id, scene=audit_scene):
|
||||
atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"])
|
||||
hour = _cn_hour(row["created_at"]) if by_hour else None
|
||||
g = _grp(
|
||||
_key(
|
||||
day,
|
||||
row["user_id"],
|
||||
atype,
|
||||
row.get("feed_scene"),
|
||||
row.get("app_env"),
|
||||
row.get("our_code_id"),
|
||||
hour,
|
||||
)
|
||||
)
|
||||
g["expected_coin"] += int(row["expected_coin"])
|
||||
g["actual_coin"] += int(row["actual_coin"])
|
||||
g["records"].append(
|
||||
{
|
||||
"record_id": row["record_id"],
|
||||
"created_at": row["created_at"],
|
||||
"status": row["status"],
|
||||
"ecpm": row["ecpm"],
|
||||
"ecpm_factor": row["ecpm_factor"],
|
||||
"units": row["units"],
|
||||
"lt_index_start": row["lt_index_start"],
|
||||
"lt_index_end": row["lt_index_end"],
|
||||
"lt_factor_start": row["lt_factor_start"],
|
||||
"lt_factor_end": row["lt_factor_end"],
|
||||
"expected_coin": row["expected_coin"],
|
||||
"actual_coin": row["actual_coin"],
|
||||
"matched": row["matched"],
|
||||
}
|
||||
)
|
||||
|
||||
rows = list(groups.values())
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
r["report_date"],
|
||||
r["user_id"],
|
||||
r["hour"] if r["hour"] is not None else -1,
|
||||
r["ad_type"] or "",
|
||||
r["feed_scene"] or "",
|
||||
r["our_code_id"] or "",
|
||||
)
|
||||
)
|
||||
|
||||
daily_map: dict[str, dict] = {}
|
||||
type_map: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
d = daily_map.setdefault(
|
||||
row["report_date"],
|
||||
{
|
||||
"date": row["report_date"],
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
},
|
||||
)
|
||||
d["impressions"] += row["impressions"]
|
||||
d["revenue_yuan"] += row["revenue_yuan"]
|
||||
d["expected_coin"] += row["expected_coin"]
|
||||
d["actual_coin"] += row["actual_coin"]
|
||||
|
||||
t = type_map.setdefault(
|
||||
row["ad_type"],
|
||||
{
|
||||
"ad_type": row["ad_type"],
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
},
|
||||
)
|
||||
t["impressions"] += row["impressions"]
|
||||
t["revenue_yuan"] += row["revenue_yuan"]
|
||||
t["expected_coin"] += row["expected_coin"]
|
||||
t["actual_coin"] += row["actual_coin"]
|
||||
|
||||
items = [
|
||||
{
|
||||
"report_date": row["report_date"],
|
||||
"user_id": row["user_id"],
|
||||
"ad_type": row["ad_type"],
|
||||
"feed_scene": row["feed_scene"],
|
||||
"app_env": row["app_env"],
|
||||
"our_code_id": row["our_code_id"],
|
||||
"hour": row["hour"],
|
||||
"impressions": row["impressions"],
|
||||
"revenue_yuan": round(row["revenue_yuan"], 6),
|
||||
"expected_coin": row["expected_coin"],
|
||||
"actual_coin": row["actual_coin"],
|
||||
"matched": all(rec["matched"] for rec in row["records"]),
|
||||
"adns": sorted(row["adns"]),
|
||||
"impression_records": sorted(
|
||||
row["impression_records"], key=lambda x: (x["created_at"], x["id"])
|
||||
),
|
||||
"records": sorted(row["records"], key=lambda x: (x["created_at"], x["record_id"])),
|
||||
}
|
||||
for row in rows[:limit]
|
||||
]
|
||||
|
||||
return {
|
||||
"total": len(rows),
|
||||
"truncated": len(rows) > limit,
|
||||
"total_impressions": sum(row["impressions"] for row in rows),
|
||||
"total_revenue_yuan": round(sum(row["revenue_yuan"] for row in rows), 6),
|
||||
"total_expected_coin": sum(row["expected_coin"] for row in rows),
|
||||
"total_actual_coin": sum(row["actual_coin"] for row in rows),
|
||||
"mismatch_count": sum(
|
||||
1 for row in rows if not all(rec["matched"] for rec in row["records"])
|
||||
),
|
||||
"daily": [
|
||||
{**d, "revenue_yuan": round(d["revenue_yuan"], 6)}
|
||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
],
|
||||
"by_ad_type": {
|
||||
key: {**value, "revenue_yuan": round(value["revenue_yuan"], 6)}
|
||||
for key, value in sorted(type_map.items())
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Admin CPS 订单对账:调用美团 query_order 并 upsert 到 cps_order。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.integrations import meituan
|
||||
from app.models.cps_order import CpsOrder
|
||||
|
||||
|
||||
def _yuan_to_cents(v: object) -> int | None:
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
if not s or s.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
return int((Decimal(s) * 100).to_integral_value())
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _ts_to_dt(ts: object) -> datetime | None:
|
||||
if ts is None:
|
||||
return None
|
||||
s = str(ts).strip()
|
||||
if not s or s.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(s), tz=timezone.utc)
|
||||
except (OSError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _map_order_fields(row: dict) -> dict:
|
||||
product_name = row.get("productName")
|
||||
if product_name and len(product_name) > 500:
|
||||
product_name = product_name[:500]
|
||||
return {
|
||||
"sid": row.get("sid") or None,
|
||||
"act_id": str(row["actId"]) if row.get("actId") is not None else None,
|
||||
"biz_line": row.get("businessLine"),
|
||||
"trade_type": row.get("tradeType"),
|
||||
"pay_price_cents": _yuan_to_cents(row.get("payPrice")),
|
||||
"commission_cents": _yuan_to_cents(row.get("profit") or row.get("cpaProfit")),
|
||||
"commission_rate": (
|
||||
str(row["commissionRate"]) if row.get("commissionRate") is not None else None
|
||||
),
|
||||
"refund_price_cents": _yuan_to_cents(row.get("refundPrice")),
|
||||
"refund_profit_cents": _yuan_to_cents(row.get("refundProfit") or row.get("cpaRefundProfit")),
|
||||
"mt_status": str(row["status"]) if row.get("status") is not None else None,
|
||||
"invalid_reason": row.get("invalidReason") or None,
|
||||
"product_name": product_name or None,
|
||||
"pay_time": _ts_to_dt(row.get("payTime")),
|
||||
"mt_update_time": _ts_to_dt(row.get("updateTime")),
|
||||
"raw": row,
|
||||
}
|
||||
|
||||
|
||||
def reconcile_orders(
|
||||
db: Session,
|
||||
*,
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
query_time_type: int = 1,
|
||||
sid: str | None = None,
|
||||
platform: int | None = None,
|
||||
business_line: list[int] | None = None,
|
||||
trade_type: int | None = 1,
|
||||
search_type: int = 2,
|
||||
max_pages: int = 200,
|
||||
) -> dict:
|
||||
fetched = inserted = updated = pages = 0
|
||||
page = 1
|
||||
scroll_id: str | None = None
|
||||
seen_scroll_ids: set[str] = set()
|
||||
|
||||
while pages < max_pages:
|
||||
resp = meituan.query_order(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
query_time_type=query_time_type,
|
||||
sid=sid,
|
||||
platform=platform,
|
||||
business_line=business_line,
|
||||
trade_type=trade_type,
|
||||
search_type=search_type,
|
||||
scroll_id=scroll_id,
|
||||
page=page,
|
||||
limit=100,
|
||||
)
|
||||
data = resp.get("data") or {}
|
||||
rows = data.get("dataList") or []
|
||||
if not rows:
|
||||
break
|
||||
|
||||
pages += 1
|
||||
for row in rows:
|
||||
order_id = str(row.get("orderId") or "").strip()
|
||||
if not order_id:
|
||||
continue
|
||||
fetched += 1
|
||||
fields = _map_order_fields(row)
|
||||
existing = db.execute(
|
||||
select(CpsOrder).where(CpsOrder.order_id == order_id)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
db.add(CpsOrder(order_id=order_id, **fields))
|
||||
inserted += 1
|
||||
else:
|
||||
for key, value in fields.items():
|
||||
setattr(existing, key, value)
|
||||
updated += 1
|
||||
|
||||
if search_type == 2:
|
||||
next_scroll_id = data.get("scrollId")
|
||||
if not next_scroll_id or next_scroll_id in seen_scroll_ids or len(rows) < 100:
|
||||
break
|
||||
seen_scroll_ids.add(next_scroll_id)
|
||||
scroll_id = next_scroll_id
|
||||
else:
|
||||
if len(rows) < 100:
|
||||
break
|
||||
page += 1
|
||||
|
||||
db.commit()
|
||||
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
|
||||
@@ -3,7 +3,7 @@
|
||||
涉钱的金币/提现复用 app.repositories.wallet(grant_coins / refresh_withdraw_status /
|
||||
reconcile_pending_withdraws),不在这里重写——重写涉钱逻辑就是给自己埋雷。
|
||||
|
||||
set_user_status / update_feedback_status 支持 commit=False,让 router 把"业务写 + 审计写"
|
||||
set_user_status / review_feedback 支持 commit=False,让 router 把"业务写 + 审计写"
|
||||
放进同一事务一起 commit(原子:改了就有审计、有审计就真改了,见 plan 风险点 1)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -54,6 +54,35 @@ def update_feedback_status(
|
||||
return feedback
|
||||
|
||||
|
||||
def review_feedback(
|
||||
db: Session,
|
||||
feedback: Feedback,
|
||||
*,
|
||||
status: str,
|
||||
admin_id: int,
|
||||
reward_coins: int | None = None,
|
||||
reject_reason: str | None = None,
|
||||
review_note: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> Feedback:
|
||||
"""审核反馈:置 adopted/rejected + 审核字段。
|
||||
|
||||
发金币(wallet.grant_coins)不在这里,由 router 放在同一事务里调。
|
||||
"""
|
||||
feedback.status = status
|
||||
feedback.reward_coins = reward_coins
|
||||
feedback.reject_reason = reject_reason
|
||||
feedback.review_note = review_note
|
||||
feedback.reviewed_by_admin_id = admin_id
|
||||
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(feedback)
|
||||
else:
|
||||
db.flush()
|
||||
return feedback
|
||||
|
||||
|
||||
def review_price_report(
|
||||
db: Session,
|
||||
report: PriceReport,
|
||||
|
||||
@@ -27,7 +27,10 @@ def cursor_paginate(
|
||||
多取 1 条探测有没有下一页;next_cursor 取本页最后一条的 id(下一页查 id < 它),
|
||||
绝不用第 limit+1 条的 id——那条既不在本页也不在下页,会每页边界丢一条(见 audit_log 同款修复)。
|
||||
"""
|
||||
if cursor is not None:
|
||||
# cursor = 上页最后一条 id 的 keyset 游标;0/None 一律视为首页(不加过滤)。
|
||||
# 页码 hook usePagedList 首页发 cursor=0,若按 id<0 过滤会把首页整页查空(列表显示"暂无数据")。
|
||||
# useCursorList 只会传 null 或正数 id,不受影响。
|
||||
if cursor:
|
||||
stmt = stmt.where(id_col < cursor)
|
||||
stmt = stmt.order_by(id_col.desc()).limit(limit + 1)
|
||||
rows = list(db.execute(stmt).scalars().all())
|
||||
@@ -37,6 +40,78 @@ def cursor_paginate(
|
||||
return items, next_cursor
|
||||
|
||||
|
||||
def offset_paginate(
|
||||
db: Session, stmt: Select, sort_clause: tuple, *, limit: int, cursor: int | None
|
||||
) -> tuple[list, int | None, int]:
|
||||
"""offset 分页 + 总数。cursor 即 offset,用于 admin Table 页码分页。"""
|
||||
total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one())
|
||||
offset = max(cursor or 0, 0)
|
||||
rows = list(
|
||||
db.execute(stmt.order_by(*sort_clause).offset(offset).limit(limit + 1)).scalars().all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
next_cursor = offset + limit if has_more else None
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
|
||||
"""给比价记录瞬态挂 phone/nickname,供 admin schema from_attributes 读取。"""
|
||||
uids = {r.user_id for r in records}
|
||||
if not uids:
|
||||
return
|
||||
rows = db.execute(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||||
).all()
|
||||
umap = {uid: (phone, nick) for uid, phone, nick in rows}
|
||||
for r in records:
|
||||
phone, nick = umap.get(r.user_id, (None, None))
|
||||
r.phone = phone
|
||||
r.nickname = nick
|
||||
|
||||
|
||||
def list_comparison_records(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
phone: str | None = None,
|
||||
status: str | None = None,
|
||||
business_type: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[ComparisonRecord], int | None, int]:
|
||||
stmt = select(ComparisonRecord)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(ComparisonRecord.user_id == user_id)
|
||||
if phone:
|
||||
stmt = stmt.where(
|
||||
ComparisonRecord.user_id.in_(
|
||||
select(User.id).where(User.phone.like(f"{phone}%"))
|
||||
)
|
||||
)
|
||||
if status:
|
||||
stmt = stmt.where(ComparisonRecord.status == status)
|
||||
if business_type:
|
||||
stmt = stmt.where(ComparisonRecord.business_type == business_type)
|
||||
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db,
|
||||
stmt,
|
||||
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
_attach_user_info(db, items)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
|
||||
rec = db.get(ComparisonRecord, record_id)
|
||||
if rec is not None:
|
||||
_attach_user_info(db, [rec])
|
||||
return rec
|
||||
|
||||
|
||||
def list_users(
|
||||
db: Session,
|
||||
*,
|
||||
|
||||
@@ -5,7 +5,8 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -13,12 +14,27 @@ from sqlalchemy.orm import Session
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.savings import SavingsRecord
|
||||
from app.models.signin import SigninBoostRecord, SigninRecord
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
|
||||
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
|
||||
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
|
||||
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
|
||||
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
||||
*COUPON_REWARD_BIZ_TYPES,
|
||||
*COMPARISON_REWARD_BIZ_TYPES,
|
||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||
*UNCLASSIFIED_FEED_BIZ_TYPES,
|
||||
)
|
||||
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
||||
MEITUAN_CPS_SETTLED_STATUS = "6"
|
||||
|
||||
|
||||
def _beijing_today_start_utc() -> datetime:
|
||||
@@ -28,20 +44,79 @@ def _beijing_today_start_utc() -> datetime:
|
||||
return start_bj.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def dashboard_overview(db: Session) -> dict:
|
||||
def _default_period_end() -> date:
|
||||
"""新版大盘不含今日,默认窗口结束日=北京时间昨天。"""
|
||||
return datetime.now(_BEIJING).date() - timedelta(days=1)
|
||||
|
||||
|
||||
def _normalize_period(date_from: date | None, date_to: date | None) -> tuple[date, date]:
|
||||
end = date_to or _default_period_end()
|
||||
start = date_from or end
|
||||
if start > end:
|
||||
start, end = end, start
|
||||
return start, end
|
||||
|
||||
|
||||
def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, datetime, datetime]:
|
||||
"""返回同一北京自然日窗口的 UTC aware 边界和北京 naive 边界。
|
||||
|
||||
user.created_at / last_login_at 是 UTC aware 口径;比较/金币等历史上有北京 naive
|
||||
写入,所以两套边界同时保留。
|
||||
"""
|
||||
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
|
||||
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
|
||||
start_utc = start_bj.astimezone(timezone.utc)
|
||||
end_utc = end_bj.astimezone(timezone.utc)
|
||||
return (
|
||||
start_utc,
|
||||
end_utc,
|
||||
start_bj.replace(tzinfo=None),
|
||||
end_bj.replace(tzinfo=None),
|
||||
)
|
||||
|
||||
|
||||
def _date_range(date_from: date, date_to: date) -> list[date]:
|
||||
days = (date_to - date_from).days
|
||||
return [date_from + timedelta(days=i) for i in range(days + 1)]
|
||||
|
||||
|
||||
def _commission_rate_percent(raw: str | None) -> Decimal | None:
|
||||
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。"""
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
if s.endswith("%"):
|
||||
return Decimal(s[:-1])
|
||||
val = Decimal(s)
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
return val / Decimal("100")
|
||||
|
||||
|
||||
def dashboard_overview(
|
||||
db: Session, *, date_from: date | None = None, date_to: date | None = None
|
||||
) -> dict:
|
||||
today_start = _beijing_today_start_utc()
|
||||
period_from, period_to = _normalize_period(date_from, date_to)
|
||||
start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to)
|
||||
|
||||
def _count(model, *conds) -> int:
|
||||
stmt = select(func.count(model.id))
|
||||
if conds:
|
||||
stmt = stmt.where(*conds)
|
||||
return db.execute(stmt).scalar_one()
|
||||
return int(db.execute(stmt).scalar_one())
|
||||
|
||||
def _sum(col, *conds) -> int:
|
||||
stmt = select(func.coalesce(func.sum(col), 0))
|
||||
if conds:
|
||||
stmt = stmt.where(*conds)
|
||||
return db.execute(stmt).scalar_one()
|
||||
return int(db.execute(stmt).scalar_one())
|
||||
|
||||
def _user_id_set(stmt) -> set[int]:
|
||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||
|
||||
# ===== 用户 =====
|
||||
by_status = dict(
|
||||
@@ -61,6 +136,204 @@ def dashboard_overview(db: Session) -> dict:
|
||||
comparison_total = _count(ComparisonRecord)
|
||||
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
|
||||
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
|
||||
period_comparison_conds = (
|
||||
ComparisonRecord.created_at >= start_local,
|
||||
ComparisonRecord.created_at < end_local,
|
||||
)
|
||||
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
|
||||
period_comparison_success = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
)
|
||||
period_comparison_success_rate = (
|
||||
round(period_comparison_success / period_comparison_total, 4)
|
||||
if period_comparison_total
|
||||
else 0.0
|
||||
)
|
||||
period_saved_positive_count = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
)
|
||||
period_saved_positive_sum = _sum(
|
||||
ComparisonRecord.saved_amount_cents,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
)
|
||||
period_avg_saved_cents = (
|
||||
round(period_saved_positive_sum / period_saved_positive_count)
|
||||
if period_saved_positive_count
|
||||
else None
|
||||
)
|
||||
period_avg_duration_ms = db.execute(
|
||||
select(func.avg(ComparisonRecord.total_ms)).where(
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
ComparisonRecord.total_ms > 0,
|
||||
)
|
||||
).scalar_one()
|
||||
period_avg_duration_ms = (
|
||||
round(float(period_avg_duration_ms))
|
||||
if period_avg_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
|
||||
ordered_exists = (
|
||||
select(SavingsRecord.id)
|
||||
.where(
|
||||
SavingsRecord.user_id == ComparisonRecord.user_id,
|
||||
SavingsRecord.source == "compare",
|
||||
SavingsRecord.shop_name.is_not(None),
|
||||
SavingsRecord.shop_name == ComparisonRecord.store_name,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
period_ordered_count = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.store_name.is_not(None),
|
||||
ordered_exists,
|
||||
)
|
||||
|
||||
# ===== 日期窗口用户 =====
|
||||
period_new_user_ids = _user_id_set(
|
||||
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
|
||||
)
|
||||
login_user_ids = _user_id_set(
|
||||
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
|
||||
)
|
||||
compare_user_ids = _user_id_set(
|
||||
select(ComparisonRecord.user_id).where(*period_comparison_conds)
|
||||
)
|
||||
coupon_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date >= period_from,
|
||||
CouponPromptEngagement.engage_date <= period_to,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
period_active_user_ids = login_user_ids | compare_user_ids | coupon_user_ids
|
||||
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
|
||||
period_retention_rate = (
|
||||
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
|
||||
if period_new_user_ids
|
||||
else None
|
||||
)
|
||||
trend_points: list[dict] = []
|
||||
for cur_date in _date_range(period_from, period_to):
|
||||
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
|
||||
cur_date, cur_date
|
||||
)
|
||||
daily_comparison_conds = (
|
||||
ComparisonRecord.created_at >= day_start_local,
|
||||
ComparisonRecord.created_at < day_end_local,
|
||||
)
|
||||
daily_login_user_ids = _user_id_set(
|
||||
select(User.id).where(
|
||||
User.last_login_at >= day_start_utc,
|
||||
User.last_login_at < day_end_utc,
|
||||
)
|
||||
)
|
||||
daily_compare_user_ids = _user_id_set(
|
||||
select(ComparisonRecord.user_id).where(*daily_comparison_conds)
|
||||
)
|
||||
daily_coupon_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == cur_date,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
trend_points.append(
|
||||
{
|
||||
"date": cur_date,
|
||||
"active_users": len(
|
||||
daily_login_user_ids | daily_compare_user_ids | daily_coupon_user_ids
|
||||
),
|
||||
"new_users": _count(
|
||||
User,
|
||||
User.created_at >= day_start_utc,
|
||||
User.created_at < day_end_utc,
|
||||
),
|
||||
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
|
||||
}
|
||||
)
|
||||
|
||||
period_coin_conds = (
|
||||
CoinTransaction.created_at >= start_local,
|
||||
CoinTransaction.created_at < end_local,
|
||||
CoinTransaction.amount > 0,
|
||||
)
|
||||
period_reward_video_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
)
|
||||
period_feed_ad_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "feed_ad_reward",
|
||||
)
|
||||
period_signin_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
)
|
||||
period_signin_boost_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "signin_boost",
|
||||
)
|
||||
period_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.like("task_%"),
|
||||
)
|
||||
period_coupon_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
|
||||
)
|
||||
period_comparison_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
|
||||
)
|
||||
period_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
|
||||
)
|
||||
period_meituan_orders = list(
|
||||
db.execute(
|
||||
select(CpsOrder).where(
|
||||
CpsOrder.pay_time >= start_utc,
|
||||
CpsOrder.pay_time < end_utc,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
period_meituan_valid_orders = [
|
||||
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
|
||||
]
|
||||
period_meituan_hit_count = 0
|
||||
period_meituan_miss_count = 0
|
||||
period_meituan_unknown_rate_count = 0
|
||||
for order in period_meituan_valid_orders:
|
||||
rate = _commission_rate_percent(order.commission_rate)
|
||||
if rate is None:
|
||||
period_meituan_unknown_rate_count += 1
|
||||
elif rate < Decimal("1"):
|
||||
period_meituan_miss_count += 1
|
||||
else:
|
||||
period_meituan_hit_count += 1
|
||||
period_meituan_hit_denominator = period_meituan_hit_count + period_meituan_miss_count
|
||||
period_meituan_hit_rate = (
|
||||
round(period_meituan_hit_count / period_meituan_hit_denominator, 4)
|
||||
if period_meituan_hit_denominator
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"users": {
|
||||
@@ -119,7 +392,61 @@ def dashboard_overview(db: Session) -> dict:
|
||||
"success": comparison_success,
|
||||
"success_rate": success_rate,
|
||||
},
|
||||
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
|
||||
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
|
||||
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
|
||||
"period": {
|
||||
"date_from": period_from,
|
||||
"date_to": period_to,
|
||||
"users": {
|
||||
"new": len(period_new_user_ids),
|
||||
"active": len(period_active_user_ids),
|
||||
"retained_new_users": len(period_retained_new_user_ids),
|
||||
"retention_rate": period_retention_rate,
|
||||
"retention_note": (
|
||||
"近似口径:登录(last_login_at)+已上报比价记录+领券claim_started;"
|
||||
"尚不包含未完成上报的比价开始事件"
|
||||
),
|
||||
},
|
||||
"comparison": {
|
||||
"total": period_comparison_total,
|
||||
"success": period_comparison_success,
|
||||
"success_rate": period_comparison_success_rate,
|
||||
"ordered": period_ordered_count,
|
||||
"average_duration_ms": period_avg_duration_ms,
|
||||
"average_saved_cents": period_avg_saved_cents,
|
||||
},
|
||||
"coins": {
|
||||
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
|
||||
"reward_video_coin_total": period_reward_video_coin_total,
|
||||
"feed_ad_coin_total": period_feed_ad_coin_total,
|
||||
"signin_coin_total": period_signin_coin_total,
|
||||
"signin_boost_coin_total": period_signin_boost_coin_total,
|
||||
"task_coin_total": period_task_coin_total,
|
||||
"coupon_reward_coin_total": period_coupon_reward_coin_total,
|
||||
"comparison_reward_coin_total": period_comparison_reward_coin_total,
|
||||
"regular_task_coin_total": period_regular_task_coin_total,
|
||||
},
|
||||
"cash": {
|
||||
"withdraw_success_cents": _sum(
|
||||
WithdrawOrder.amount_cents,
|
||||
WithdrawOrder.status == "success",
|
||||
WithdrawOrder.created_at >= start_local,
|
||||
WithdrawOrder.created_at < end_local,
|
||||
),
|
||||
},
|
||||
"trend": trend_points,
|
||||
},
|
||||
"feedback": {
|
||||
"new": _count(Feedback, Feedback.status.in_(("pending", "new"))),
|
||||
},
|
||||
"cps": {
|
||||
"available": True,
|
||||
"note": "美团 CPS 读 cps_order 对账订单;淘宝/京东佣金暂空",
|
||||
"meituan_order_count": len(period_meituan_valid_orders),
|
||||
"meituan_commission_cents": sum(
|
||||
o.commission_cents or 0 for o in period_meituan_valid_orders
|
||||
),
|
||||
"meituan_hit_count": period_meituan_hit_count,
|
||||
"meituan_miss_count": period_meituan_miss_count,
|
||||
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
|
||||
"meituan_hit_rate": period_meituan_hit_rate,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""admin 广告收益报表。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import ad_revenue
|
||||
from app.admin.schemas.ad_revenue import (
|
||||
AdRevenueDaily,
|
||||
AdRevenueReportOut,
|
||||
AdRevenueRow,
|
||||
AdRevenueTypeSummary,
|
||||
)
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/ad-revenue-report",
|
||||
tags=["admin-ad-revenue-report"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
_MAX_RANGE_DAYS = 92
|
||||
|
||||
|
||||
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return _date.fromisoformat(value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
||||
|
||||
|
||||
@router.get("", response_model=AdRevenueReportOut, summary="广告收益报表")
|
||||
def get_ad_revenue_report(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[str | None, Query(description="起始日 北京时间 YYYY-MM-DD")] = None,
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京时间 YYYY-MM-DD")] = None,
|
||||
user_id: Annotated[int | None, Query(description="只看某用户")] = None,
|
||||
ad_type: Annotated[str | None, Query(description="reward_video / feed / draw")] = None,
|
||||
granularity: Annotated[str, Query(description="day / hour")] = "day",
|
||||
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
|
||||
) -> AdRevenueReportOut:
|
||||
today = cn_today()
|
||||
d_from = _parse_day(date_from, field="date_from", default=today)
|
||||
d_to = _parse_day(date_to, field="date_to", default=d_from)
|
||||
if d_to < d_from:
|
||||
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
|
||||
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
|
||||
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天")
|
||||
if ad_type is not None and ad_type not in {"reward_video", "feed", "draw"}:
|
||||
raise HTTPException(status_code=422, detail="ad_type 需为 reward_video/feed/draw")
|
||||
if granularity not in {"day", "hour"}:
|
||||
raise HTTPException(status_code=422, detail="granularity 需为 day/hour")
|
||||
|
||||
result = ad_revenue.ad_revenue_report(
|
||||
db,
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
user_id=user_id,
|
||||
ad_type=ad_type,
|
||||
granularity=granularity,
|
||||
limit=limit,
|
||||
)
|
||||
return AdRevenueReportOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
daily=[AdRevenueDaily(**d) for d in result["daily"]],
|
||||
total=result["total"],
|
||||
truncated=result["truncated"],
|
||||
total_impressions=result["total_impressions"],
|
||||
total_revenue_yuan=result["total_revenue_yuan"],
|
||||
total_expected_coin=result["total_expected_coin"],
|
||||
total_actual_coin=result["total_actual_coin"],
|
||||
mismatch_count=result["mismatch_count"],
|
||||
by_ad_type={
|
||||
key: AdRevenueTypeSummary(**value)
|
||||
for key, value in result["by_ad_type"].items()
|
||||
},
|
||||
items=[AdRevenueRow(**r) for r in result["items"]],
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""admin 比价记录 debug:按 user_id / phone 查列表 + 单条详情。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/comparison-records",
|
||||
tags=["admin-comparison"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[AdminComparisonListItem], summary="比价记录列表")
|
||||
def list_comparison_records(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminComparisonListItem]:
|
||||
items, next_cursor, total = queries.list_comparison_records(
|
||||
db,
|
||||
user_id=user_id,
|
||||
phone=phone,
|
||||
status=status,
|
||||
business_type=business_type,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminComparisonListItem.model_validate(r) for r in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{record_id}", response_model=AdminComparisonDetail, summary="比价记录详情")
|
||||
def get_comparison_record(record_id: int, db: AdminDb) -> AdminComparisonDetail:
|
||||
rec = queries.get_comparison_record(db, record_id)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
return AdminComparisonDetail.model_validate(rec)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""admin CPS 对账接口。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date, datetime, time, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from app.admin.deps import AdminDb, require_role
|
||||
from app.admin.repositories import cps_orders
|
||||
from app.admin.schemas.cps import CpsReconcileResult
|
||||
from app.integrations.meituan import MeituanCpsError
|
||||
from app.models.admin import AdminUser
|
||||
|
||||
router = APIRouter(prefix="/admin/api/cps", tags=["admin-cps"])
|
||||
|
||||
_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 _day_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:
|
||||
end_dt = datetime.now(_BEIJING)
|
||||
start_dt = end_dt - timedelta(days=days)
|
||||
else:
|
||||
end_day = date_to or date_from
|
||||
start_day = date_from or end_day
|
||||
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, time.min, tzinfo=_BEIJING)
|
||||
end_dt = datetime.combine(end_day + timedelta(days=1), 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:
|
||||
_ = request, admin
|
||||
start_ts, end_ts = _day_range_to_ts(
|
||||
_parse_day(date_from, field="date_from"),
|
||||
_parse_day(date_to, field="date_to"),
|
||||
days,
|
||||
)
|
||||
try:
|
||||
result = cps_orders.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
|
||||
return CpsReconcileResult(**result)
|
||||
@@ -1,7 +1,9 @@
|
||||
"""admin 数据大盘(只读聚合)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import stats
|
||||
@@ -15,5 +17,11 @@ router = APIRouter(
|
||||
|
||||
|
||||
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
|
||||
def overview(db: AdminDb) -> DashboardOverview:
|
||||
return DashboardOverview.model_validate(stats.dashboard_overview(db))
|
||||
def overview(
|
||||
db: AdminDb,
|
||||
date_from: date | None = Query(None, description="北京时间自然日起始日 YYYY-MM-DD"),
|
||||
date_to: date | None = Query(None, description="北京时间自然日结束日 YYYY-MM-DD"),
|
||||
) -> DashboardOverview:
|
||||
return DashboardOverview.model_validate(
|
||||
stats.dashboard_overview(db, date_from=date_from, date_to=date_to)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""admin 反馈工单:列表(读)+ 标记已处理(写,带审计)。"""
|
||||
"""admin 反馈工单:列表(读)+ 采纳发金币 / 拒绝写原因(写,带审计)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
@@ -9,9 +9,10 @@ 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 mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.feedback import FeedbackOut
|
||||
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.feedback import Feedback
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/feedbacks",
|
||||
@@ -47,10 +48,101 @@ def handle_feedback(
|
||||
if fb is None:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
before = fb.status
|
||||
mutations.update_feedback_status(db, fb, status="handled", commit=False)
|
||||
mutations.update_feedback_status(db, fb, status="adopted", commit=False)
|
||||
write_audit(
|
||||
db, admin, action="feedback.handle", target_type="feedback", target_id=feedback_id,
|
||||
detail={"before": before, "after": "handled"}, ip=get_client_ip(request), commit=False,
|
||||
detail={"before": before, "after": "adopted", "deprecated": True},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/approve", response_model=OkResponse, summary="采纳反馈并发金币")
|
||||
def approve_feedback(
|
||||
feedback_id: int,
|
||||
body: FeedbackApproveRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
fb = db.get(Feedback, feedback_id)
|
||||
if fb is None:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
if fb.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"该反馈已审核过(当前 {fb.status}),不可重复操作")
|
||||
|
||||
note = body.note.strip() if body.note else None
|
||||
mutations.review_feedback(
|
||||
db,
|
||||
fb,
|
||||
status="adopted",
|
||||
admin_id=admin.id,
|
||||
reward_coins=body.reward_coins,
|
||||
review_note=note,
|
||||
commit=False,
|
||||
)
|
||||
acc, _ = wallet_repo.grant_coins(
|
||||
db,
|
||||
fb.user_id,
|
||||
body.reward_coins,
|
||||
biz_type="feedback_reward",
|
||||
ref_id=str(fb.id),
|
||||
remark="意见反馈被采纳",
|
||||
)
|
||||
write_audit(
|
||||
db,
|
||||
admin,
|
||||
action="feedback.approve",
|
||||
target_type="feedback",
|
||||
target_id=feedback_id,
|
||||
detail={
|
||||
"user_id": fb.user_id,
|
||||
"reward_coins": body.reward_coins,
|
||||
"balance_after": acc.coin_balance,
|
||||
"note": note,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/reject", response_model=OkResponse, summary="拒绝反馈并写明原因")
|
||||
def reject_feedback(
|
||||
feedback_id: int,
|
||||
body: FeedbackRejectRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
fb = db.get(Feedback, feedback_id)
|
||||
if fb is None:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
if fb.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"该反馈已审核过(当前 {fb.status}),不可重复操作")
|
||||
|
||||
reason = body.reason.strip()
|
||||
note = body.note.strip() if body.note else None
|
||||
mutations.review_feedback(
|
||||
db,
|
||||
fb,
|
||||
status="rejected",
|
||||
admin_id=admin.id,
|
||||
reject_reason=reason,
|
||||
review_note=note,
|
||||
commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db,
|
||||
admin,
|
||||
action="feedback.reject",
|
||||
target_type="feedback",
|
||||
target_id=feedback_id,
|
||||
detail={"user_id": fb.user_id, "reason": reason, "note": note},
|
||||
ip=get_client_ip(request),
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""广告收益报表 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdRevenueImpression(BaseModel):
|
||||
id: int = Field(..., description="ad_ecpm_record 主键")
|
||||
created_at: datetime
|
||||
ecpm: str
|
||||
revenue_yuan: float
|
||||
adn: str | None = None
|
||||
slot_id: str | None = None
|
||||
|
||||
|
||||
class AdRevenueRecord(BaseModel):
|
||||
record_id: int
|
||||
created_at: datetime
|
||||
status: str
|
||||
ecpm: str | None = None
|
||||
ecpm_factor: float | None = None
|
||||
units: int
|
||||
lt_index_start: int | None = None
|
||||
lt_index_end: int | None = None
|
||||
lt_factor_start: float | None = None
|
||||
lt_factor_end: float | None = None
|
||||
expected_coin: int
|
||||
actual_coin: int
|
||||
matched: bool
|
||||
|
||||
|
||||
class AdRevenueDaily(BaseModel):
|
||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||
impressions: int
|
||||
revenue_yuan: float
|
||||
expected_coin: int
|
||||
actual_coin: int
|
||||
|
||||
|
||||
class AdRevenueTypeSummary(BaseModel):
|
||||
ad_type: str
|
||||
impressions: int
|
||||
revenue_yuan: float
|
||||
expected_coin: int
|
||||
actual_coin: int
|
||||
|
||||
|
||||
class AdRevenueRow(BaseModel):
|
||||
report_date: str
|
||||
user_id: int
|
||||
ad_type: str
|
||||
feed_scene: str | None = None
|
||||
app_env: str | None = None
|
||||
our_code_id: str | None = None
|
||||
hour: int | None = None
|
||||
impressions: int
|
||||
revenue_yuan: float
|
||||
expected_coin: int
|
||||
actual_coin: int
|
||||
matched: bool
|
||||
adns: list[str] = Field(default_factory=list)
|
||||
impression_records: list[AdRevenueImpression] = Field(default_factory=list)
|
||||
records: list[AdRevenueRecord] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdRevenueReportOut(BaseModel):
|
||||
date_from: str
|
||||
date_to: str
|
||||
daily: list[AdRevenueDaily]
|
||||
total: int
|
||||
truncated: bool
|
||||
total_impressions: int
|
||||
total_revenue_yuan: float
|
||||
total_expected_coin: int
|
||||
total_actual_coin: int
|
||||
mismatch_count: int
|
||||
by_ad_type: dict[str, AdRevenueTypeSummary] = Field(default_factory=dict)
|
||||
items: list[AdRevenueRow]
|
||||
@@ -13,6 +13,7 @@ class CursorPage(BaseModel, Generic[T]):
|
||||
|
||||
items: list[T]
|
||||
next_cursor: int | None = None
|
||||
total: int | None = None
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""admin 比价记录 debug 页 schema。phone/nickname 由 queries 瞬态挂在 ORM 实例上。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AdminComparisonListItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
phone: str | None = None
|
||||
nickname: str | None = None
|
||||
business_type: str
|
||||
trace_id: str
|
||||
trace_url: str | None = None
|
||||
status: str
|
||||
information: str | None = None
|
||||
store_name: str | None = None
|
||||
source_platform_name: str | None = None
|
||||
best_platform_name: str | None = None
|
||||
source_price_cents: int | None = None
|
||||
best_price_cents: int | None = None
|
||||
saved_amount_cents: int | None = None
|
||||
total_ms: int | None = None
|
||||
step_count: int | None = None
|
||||
llm_call_count: int | None = None
|
||||
retry_count: int | None = None
|
||||
input_tokens: int | None = None
|
||||
output_tokens: int | None = None
|
||||
device_model: str | None = None
|
||||
rom_vendor: str | None = None
|
||||
rom_name: str | None = None
|
||||
android_version: str | None = None
|
||||
app_version: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminComparisonDetail(AdminComparisonListItem):
|
||||
source_platform_id: str | None = None
|
||||
source_package: str | None = None
|
||||
best_platform_id: str | None = None
|
||||
best_deeplink: str | None = None
|
||||
is_source_best: bool | None = None
|
||||
total_dish_count: int | None = None
|
||||
skipped_dish_count: int | None = None
|
||||
device_id: str | None = None
|
||||
items: list = []
|
||||
comparison_results: list = []
|
||||
skipped_dish_names: list = []
|
||||
device_manufacturer: str | None = None
|
||||
rom_version: int | None = None
|
||||
android_sdk: int | None = None
|
||||
app_version_code: int | None = None
|
||||
source_app_version: str | None = None
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
llm_calls: list | None = None
|
||||
raw_payload: dict | None = None
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Admin CPS schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CpsReconcileResult(BaseModel):
|
||||
fetched: int
|
||||
inserted: int
|
||||
updated: int
|
||||
pages: int
|
||||
@@ -1,6 +1,8 @@
|
||||
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -38,6 +40,56 @@ class DashboardComparison(BaseModel):
|
||||
success_rate: float
|
||||
|
||||
|
||||
class DashboardPeriodUsers(BaseModel):
|
||||
new: int
|
||||
active: int
|
||||
retained_new_users: int
|
||||
retention_rate: float | None = None
|
||||
retention_note: str
|
||||
|
||||
|
||||
class DashboardPeriodComparison(BaseModel):
|
||||
total: int
|
||||
success: int
|
||||
success_rate: float
|
||||
ordered: int
|
||||
average_duration_ms: int | None = None
|
||||
average_saved_cents: int | None = None
|
||||
|
||||
|
||||
class DashboardPeriodCoins(BaseModel):
|
||||
granted_total: int
|
||||
reward_video_coin_total: int = 0
|
||||
feed_ad_coin_total: int = 0
|
||||
signin_coin_total: int = 0
|
||||
signin_boost_coin_total: int = 0
|
||||
task_coin_total: int = 0
|
||||
coupon_reward_coin_total: int = 0
|
||||
comparison_reward_coin_total: int = 0
|
||||
regular_task_coin_total: int = 0
|
||||
|
||||
|
||||
class DashboardPeriodCash(BaseModel):
|
||||
withdraw_success_cents: int
|
||||
|
||||
|
||||
class DashboardTrendPoint(BaseModel):
|
||||
date: date
|
||||
active_users: int
|
||||
new_users: int
|
||||
comparisons: int
|
||||
|
||||
|
||||
class DashboardPeriod(BaseModel):
|
||||
date_from: date
|
||||
date_to: date
|
||||
users: DashboardPeriodUsers
|
||||
comparison: DashboardPeriodComparison
|
||||
coins: DashboardPeriodCoins
|
||||
cash: DashboardPeriodCash
|
||||
trend: list[DashboardTrendPoint] = []
|
||||
|
||||
|
||||
class DashboardFeedback(BaseModel):
|
||||
new: int
|
||||
|
||||
@@ -45,6 +97,12 @@ class DashboardFeedback(BaseModel):
|
||||
class DashboardCps(BaseModel):
|
||||
available: bool
|
||||
note: str
|
||||
meituan_order_count: int = 0
|
||||
meituan_commission_cents: int = 0
|
||||
meituan_hit_count: int = 0
|
||||
meituan_miss_count: int = 0
|
||||
meituan_unknown_rate_count: int = 0
|
||||
meituan_hit_rate: float | None = None
|
||||
|
||||
|
||||
class DashboardOverview(BaseModel):
|
||||
@@ -52,5 +110,6 @@ class DashboardOverview(BaseModel):
|
||||
coins: DashboardCoins
|
||||
cash: DashboardCash
|
||||
comparison: DashboardComparison
|
||||
period: DashboardPeriod
|
||||
feedback: DashboardFeedback
|
||||
cps: DashboardCps
|
||||
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
|
||||
|
||||
|
||||
class FeedbackOut(BaseModel):
|
||||
@@ -15,4 +17,23 @@ class FeedbackOut(BaseModel):
|
||||
contact: str
|
||||
images: list[str] | None = None
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
review_note: str | None = None
|
||||
reviewed_by_admin_id: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FeedbackApproveRequest(BaseModel):
|
||||
reward_coins: int = Field(
|
||||
ge=1,
|
||||
le=FEEDBACK_REWARD_MAX_COINS,
|
||||
description="采纳后发放金币数",
|
||||
)
|
||||
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注")
|
||||
|
||||
|
||||
class FeedbackRejectRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
|
||||
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
|
||||
|
||||
Reference in New Issue
Block a user