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:
lowmaster-chen
2026-06-28 09:56:13 +08:00
parent 900b64d4f9
commit e1bd0e3ef7
49 changed files with 2376 additions and 119 deletions
+6
View File
@@ -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)
+14 -4
View File
@@ -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]
+254
View File
@@ -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,
}
+130
View File
@@ -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}
+30 -1
View File
@@ -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,
+76 -1
View File
@@ -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,
*,
+334 -7
View File
@@ -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,
},
}
+84
View File
@@ -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"]],
)
+51
View File
@@ -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)
+74
View File
@@ -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)
+11 -3
View File
@@ -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)
)
+96 -4
View File
@@ -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()
+80
View File
@@ -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]
+1
View File
@@ -13,6 +13,7 @@ class CursorPage(BaseModel, Generic[T]):
items: list[T]
next_cursor: int | None = None
total: int | None = None
class OkResponse(BaseModel):
+61
View File
@@ -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
+11
View File
@@ -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
+59
View File
@@ -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
+22 -1
View File
@@ -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="运营内部审核备注")