合并 main 并解决 API 文档冲突
This commit is contained in:
@@ -26,12 +26,14 @@ from app.admin.routers.cps import router as cps_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.device_liveness import router as device_liveness_router
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.analytics_health import router as analytics_health_router
|
||||
from app.admin.routers.event_logs import router as event_logs_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.feedback_qr import router as feedback_qr_router
|
||||
from app.admin.routers.onboarding import router as onboarding_router
|
||||
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
||||
from app.admin.routers.price_report import router as price_report_router
|
||||
from app.admin.routers.roles import router as roles_router
|
||||
from app.admin.routers.users import router as users_router
|
||||
from app.admin.routers.wallet import router as wallet_router
|
||||
from app.admin.routers.withdraw import router as withdraw_router
|
||||
@@ -96,8 +98,10 @@ admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(price_report_router)
|
||||
admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(event_logs_router)
|
||||
admin_app.include_router(analytics_health_router)
|
||||
admin_app.include_router(feedback_qr_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(roles_router)
|
||||
admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(comparison_router)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""admin 后台「页面权限目录」—— RBAC 的权限侧单一真源。
|
||||
|
||||
一个权限 = 一个页面(= 左侧导航项)。角色持有一组 page key,登录后左侧只展示这些页(见前端
|
||||
layout.tsx 按 pages 过滤导航)。key 必须与前端路由一级对齐(/dashboard → "dashboard")。
|
||||
|
||||
super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。新增页面 = 这里加一条 +
|
||||
前端导航加对应项(key 一致即可被各角色勾选可见)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
SUPER_ADMIN_ROLE = "super_admin"
|
||||
# 「自定义」哨兵角色:不是 admin_role 表里的行,而是标记「这个人的可见页由 pages_override 决定」。
|
||||
# admin_user.role == CUSTOM_ROLE 时,有效可见页取 admin_user.pages_override(见 auth._admin_out_with_pages)。
|
||||
CUSTOM_ROLE = "custom"
|
||||
|
||||
# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级
|
||||
PERMISSION_CATALOG: list[dict] = [
|
||||
{"group": "看板", "pages": [
|
||||
{"key": "dashboard", "label": "数据大盘"},
|
||||
{"key": "coupon-data", "label": "领券数据"},
|
||||
{"key": "ad-revenue-report", "label": "广告收益"},
|
||||
{"key": "comparison-records", "label": "比价记录"},
|
||||
{"key": "cps", "label": "CPS收益"},
|
||||
{"key": "device-liveness", "label": "设备存活"},
|
||||
]},
|
||||
{"group": "奖励审核", "pages": [
|
||||
{"key": "withdraws", "label": "提现审核"},
|
||||
{"key": "price-reports", "label": "低价审核"},
|
||||
{"key": "feedbacks", "label": "用户反馈"},
|
||||
]},
|
||||
{"group": "数据配置", "pages": [
|
||||
{"key": "config", "label": "系统配置"},
|
||||
{"key": "ad-revenue", "label": "广告配置"},
|
||||
{"key": "users", "label": "用户管理"},
|
||||
]},
|
||||
{"group": "其他", "pages": [
|
||||
{"key": "admins", "label": "权限管理"},
|
||||
{"key": "event-logs", "label": "埋点日志"},
|
||||
{"key": "audit-logs", "label": "审计日志"},
|
||||
]},
|
||||
]
|
||||
|
||||
# 全部页面 key(super_admin 有效可见 = 此全集;也用于校验角色 pages 合法性)
|
||||
ALL_PAGE_KEYS: list[str] = [p["key"] for g in PERMISSION_CATALOG for p in g["pages"]]
|
||||
_ALL_SET = set(ALL_PAGE_KEYS)
|
||||
|
||||
# 内建角色定义(种子 / ensure 兜底的单一真源):(key, 中文展示名, 默认可见页)。
|
||||
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES;super_admin 恒全权(pages 空、bypass)。
|
||||
# key 承重(require_role / admin_user.role),勿改;label 为 UI 展示名。
|
||||
BUILTIN_ROLES: list[dict] = [
|
||||
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
|
||||
{"name": "operator", "label": "运营", "pages": [
|
||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks",
|
||||
]},
|
||||
{"name": "finance", "label": "财务", "pages": [
|
||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||
]},
|
||||
{"name": "tech", "label": "技术", "pages": [
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
||||
]},
|
||||
]
|
||||
|
||||
# 内建 key → 中文展示名(展示 / 自愈用)
|
||||
BUILTIN_LABELS: dict[str, str] = {r["name"]: r["label"] for r in BUILTIN_ROLES}
|
||||
|
||||
|
||||
def sanitize_pages(pages: list[str] | None) -> list[str]:
|
||||
"""过滤掉不在目录里的 key(去重、保序),防脏数据/目录收缩后的悬空 key。"""
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for k in pages or []:
|
||||
if k in _ALL_SET and k not in seen:
|
||||
seen.add(k)
|
||||
out.append(k)
|
||||
return out
|
||||
|
||||
|
||||
def effective_pages(role_name: str, role_pages: list[str] | None) -> list[str]:
|
||||
"""某角色的有效可见页:super_admin → 全部;其余 → 其 pages 与目录取交(自愈悬空 key)。"""
|
||||
if role_name == SUPER_ADMIN_ROLE:
|
||||
return list(ALL_PAGE_KEYS)
|
||||
return sanitize_pages(role_pages)
|
||||
@@ -385,10 +385,33 @@ def ad_revenue_report(
|
||||
for k, v in type_map.items()
|
||||
}
|
||||
|
||||
# DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日,
|
||||
# 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。
|
||||
is_today = date_from == date_to == rewards.cn_today().isoformat()
|
||||
dau = admin_stats.today_dau(db) if is_today else None
|
||||
# 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events——
|
||||
# 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算,
|
||||
# 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0;
|
||||
# 改为服务端在全量上聚合下发(也顺带不受 limit 分页截断影响)。feed_scene 为空(激励视频 /
|
||||
# 旧数据)不计入任何场景桶。
|
||||
scene_map: dict[str, dict] = {}
|
||||
for e in events:
|
||||
sc = e.get("feed_scene")
|
||||
if not sc:
|
||||
continue
|
||||
s = scene_map.get(sc)
|
||||
if s is None:
|
||||
s = {"impressions": 0, "revenue_yuan": 0.0}
|
||||
scene_map[sc] = s
|
||||
s["impressions"] += e["impressions"]
|
||||
s["revenue_yuan"] += e["revenue_yuan"]
|
||||
scene_stats = {
|
||||
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
|
||||
for k, v in scene_map.items()
|
||||
}
|
||||
|
||||
# DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间
|
||||
# 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径,
|
||||
# 不随 user / ad_type / feed_scene / app_env 筛选变化(活跃用户口径无这些维度)。
|
||||
dau = admin_stats.period_active_dau(
|
||||
db, _date.fromisoformat(date_from), _date.fromisoformat(date_to)
|
||||
)
|
||||
|
||||
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
|
||||
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
|
||||
@@ -416,6 +439,7 @@ def ad_revenue_report(
|
||||
"daily": daily,
|
||||
"hourly": hourly,
|
||||
"type_stats": type_stats,
|
||||
"scene_stats": scene_stats,
|
||||
"dau": dau,
|
||||
"items": main_rows[offset:offset + limit],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""admin_role 表 CRUD + 「角色 → 有效可见页」解析。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.permissions import (
|
||||
ALL_PAGE_KEYS,
|
||||
BUILTIN_ROLES,
|
||||
SUPER_ADMIN_ROLE,
|
||||
sanitize_pages,
|
||||
)
|
||||
from app.models.admin_role import AdminRole
|
||||
|
||||
|
||||
def ensure_builtin_roles(db: Session) -> None:
|
||||
"""空表时播种内建角色(管理员/运营/财务/技术)。幂等:表非空即 no-op。
|
||||
给 create_all 环境(测试 / 未跑迁移的全新库)兜底,迁移已播种则不重复。
|
||||
仅 super_admin 为 is_builtin(锁定不可改删);运营/财务/技术可编辑页/删除。"""
|
||||
if db.execute(select(AdminRole.id).limit(1)).first() is not None:
|
||||
return
|
||||
db.add_all([
|
||||
AdminRole(
|
||||
name=r["name"], label=r["label"], pages=list(r["pages"]),
|
||||
is_builtin=(r["name"] == SUPER_ADMIN_ROLE),
|
||||
)
|
||||
for r in BUILTIN_ROLES
|
||||
])
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_roles(db: Session) -> list[AdminRole]:
|
||||
ensure_builtin_roles(db)
|
||||
# super_admin(内建)恒排最前,其余按创建序
|
||||
return list(
|
||||
db.execute(
|
||||
select(AdminRole).order_by(AdminRole.is_builtin.desc(), AdminRole.id)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
|
||||
def get_role(db: Session, name: str) -> AdminRole | None:
|
||||
return db.execute(
|
||||
select(AdminRole).where(AdminRole.name == name)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_role(db: Session, *, name: str, label: str, pages: list[str]) -> AdminRole:
|
||||
# 自定义角色:name(key)= label = 用户输入的名称
|
||||
role = AdminRole(name=name, label=label, pages=sanitize_pages(pages), is_builtin=False)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
def update_role(
|
||||
db: Session, role: AdminRole, *, label: str | None = None, pages: list[str] | None = None
|
||||
) -> AdminRole:
|
||||
# 只改展示名 label + 可见页;name(key)不可变(admin_user.role / require_role 承重)
|
||||
if label is not None:
|
||||
role.label = label
|
||||
if pages is not None:
|
||||
role.pages = sanitize_pages(pages)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
def delete_role(db: Session, role: AdminRole) -> None:
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
|
||||
|
||||
def effective_pages_of(db: Session, role_name: str) -> list[str]:
|
||||
"""某角色名的有效可见页:super_admin → 全部;其余 → 查表 pages 与目录取交。"""
|
||||
if role_name == SUPER_ADMIN_ROLE:
|
||||
return list(ALL_PAGE_KEYS)
|
||||
ensure_builtin_roles(db)
|
||||
role = get_role(db, role_name)
|
||||
return sanitize_pages(role.pages if role else None)
|
||||
@@ -20,12 +20,23 @@ def get_by_username(db: Session, username: str) -> AdminUser | None:
|
||||
|
||||
|
||||
def create_admin(
|
||||
db: Session, *, username: str, password: str, role: str = "operator"
|
||||
db: Session,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
role: str = "operator",
|
||||
plain_password: str | None = None,
|
||||
pages_override: list[str] | None = None,
|
||||
) -> AdminUser:
|
||||
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
|
||||
脚本/起后台建账号不传(留 None → 前端不显示密码)。
|
||||
pages_override:role=="custom" 时传专属可见页 key 列表;普通角色为 None。"""
|
||||
admin = AdminUser(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
plain_password=plain_password,
|
||||
pages_override=pages_override,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""埋点健康度聚合(埋点成功率 / 上报成功率)。
|
||||
|
||||
只存原始累计快照,查询时在 Python 侧差分聚合(admin 低频、量级小,跨 PG/SQLite 无方言坑;
|
||||
与 cps.py / coupon_data.py 同款约定)。差分按 (device_id, epoch_id, event) 分区、created_at
|
||||
升序,相邻做差、负值夹 0;每增量按其快照 created_at 归入北京天桶。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStat as H
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStatEvent as E
|
||||
|
||||
_COUNTS = ("attempted", "drop_capture", "delivered", "drop_undelivered")
|
||||
|
||||
|
||||
def diff_snapshots(rows: list[dict]) -> list[dict]:
|
||||
"""累计快照行 → 每快照增量行(纯逻辑)。
|
||||
|
||||
rows 每行含 device_id/epoch_id/event/created_at/app_ver/oem/os + 四个累计计数。
|
||||
返回每行含 dims + created_at + 四个增量 d_*(分区首行增量=累计值;负值夹 0)。
|
||||
"""
|
||||
parts: dict[tuple, list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
parts[(r["device_id"], r["epoch_id"], r["event"])].append(r)
|
||||
|
||||
out: list[dict] = []
|
||||
for group in parts.values():
|
||||
group.sort(key=lambda r: (r["created_at"], r.get("id", 0)))
|
||||
prev = {k: 0 for k in _COUNTS}
|
||||
for r in group:
|
||||
deltas = {f"d_{k}": max(0, int(r[k]) - prev[k]) for k in _COUNTS}
|
||||
out.append({
|
||||
"device_id": r["device_id"], "epoch_id": r["epoch_id"], "event": r["event"],
|
||||
"created_at": r["created_at"], "app_ver": r["app_ver"],
|
||||
"oem": r["oem"], "os": r["os"], **deltas,
|
||||
})
|
||||
prev = {k: int(r[k]) for k in _COUNTS}
|
||||
return out
|
||||
|
||||
|
||||
def _cn_day(dt: datetime) -> str:
|
||||
"""created_at(UTC 口径)→ 北京日期字符串 YYYY-MM-DD。naive 当 UTC,tz-aware 直接换算。"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.astimezone(rewards.CN_TZ).date().isoformat()
|
||||
|
||||
|
||||
def _rates(sums: dict) -> dict:
|
||||
"""由四个增量和派生两段率(分母 0 → None)。"""
|
||||
persisted_denom = sums["attempted"]
|
||||
report_denom = sums["delivered"] + sums["drop_undelivered"]
|
||||
return {
|
||||
**sums,
|
||||
"track_success_rate": (
|
||||
(sums["attempted"] - sums["drop_capture"]) / persisted_denom
|
||||
if persisted_denom else None
|
||||
),
|
||||
"report_success_rate": (
|
||||
sums["delivered"] / report_denom if report_denom else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sum_deltas(deltas: list[dict]) -> dict:
|
||||
return {k: sum(d[f"d_{k}"] for d in deltas) for k in _COUNTS}
|
||||
|
||||
|
||||
def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
"""取 [from, to) 区间行 + 每分区在 from 左侧的最后一条基线行(供第一条区间增量做差)。"""
|
||||
cols = (
|
||||
H.id, H.device_id, H.epoch_id, E.event, H.created_at,
|
||||
H.app_ver, H.oem, H.os,
|
||||
E.attempted, E.drop_capture, E.delivered, E.drop_undelivered,
|
||||
)
|
||||
in_range = db.execute(
|
||||
select(*cols).join(E, E.snapshot_id == H.id)
|
||||
.where(H.created_at >= date_from, H.created_at < date_to)
|
||||
).mappings().all()
|
||||
|
||||
# 注:基线子查询无下界扫 from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。
|
||||
# 用 max(id) 而非 max(created_at) 选"最新一条":id 严格单调,避免 SQLite 秒级时间戳撞车时选歧义。
|
||||
sub = (
|
||||
select(H.device_id, H.epoch_id, E.event, func.max(H.id).label("max_id"))
|
||||
.join(E, E.snapshot_id == H.id)
|
||||
.where(H.created_at < date_from)
|
||||
.group_by(H.device_id, H.epoch_id, E.event)
|
||||
.subquery()
|
||||
)
|
||||
baseline = db.execute(
|
||||
select(*cols).join(E, E.snapshot_id == H.id).join(
|
||||
sub, sub.c.max_id == H.id
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
return [dict(r) for r in list(baseline) + list(in_range)]
|
||||
|
||||
|
||||
def _in_range_deltas(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
"""差分后只保留 created_at ∈ [from, to) 的增量(基线行被差分用后丢弃)。
|
||||
|
||||
Python 侧过滤需对齐 tz 口径:SQLite 返回 naive UTC,PG 返回 aware UTC。
|
||||
统一转成 naive UTC 再比较,兼容两种后端。
|
||||
"""
|
||||
def _to_naive_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is not None:
|
||||
return dt.astimezone(UTC).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
from_naive = _to_naive_utc(date_from)
|
||||
to_naive = _to_naive_utc(date_to)
|
||||
deltas = diff_snapshots(_fetch_rows(db, date_from, date_to))
|
||||
return [d for d in deltas if from_naive <= _to_naive_utc(d["created_at"]) < to_naive]
|
||||
|
||||
|
||||
def overview(db: Session, date_from: datetime, date_to: datetime) -> dict:
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
return _rates(_sum_deltas(deltas))
|
||||
|
||||
|
||||
def trend(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
by_day: dict[str, list[dict]] = defaultdict(list)
|
||||
for d in deltas:
|
||||
by_day[_cn_day(d["created_at"])].append(d)
|
||||
return [
|
||||
{"day": day, **_rates(_sum_deltas(items))}
|
||||
for day, items in sorted(by_day.items())
|
||||
]
|
||||
|
||||
|
||||
def breakdown(db: Session, date_from: datetime, date_to: datetime, dim: str) -> list[dict]:
|
||||
if dim not in ("event", "app_ver", "oem"):
|
||||
raise ValueError(f"invalid dim: {dim!r}")
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
by_key: dict[str, list[dict]] = defaultdict(list)
|
||||
for d in deltas:
|
||||
by_key[d.get(dim) or "(unknown)"].append(d)
|
||||
rows = [{"key": key, **_rates(_sum_deltas(items))} for key, items in by_key.items()]
|
||||
rows.sort(key=lambda r: (r["report_success_rate"] is None, r["report_success_rate"] or 0.0))
|
||||
return rows
|
||||
@@ -5,17 +5,21 @@
|
||||
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
||||
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
||||
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
||||
- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date as _date, datetime
|
||||
from datetime import UTC, datetime
|
||||
from datetime import date as _date
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.coupon_state import CouponSession
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||||
from app.models.user import User
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
@@ -42,7 +46,47 @@ def _avg(vals: list[int]) -> int | None:
|
||||
return round(sum(vals) / len(vals)) if vals else None
|
||||
|
||||
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict:
|
||||
def _success_rates(rows: list) -> dict:
|
||||
"""平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md §3/§12):
|
||||
|
||||
- sel(s) = 勾选平台(`platforms` 空 → 全领三档 DEFAULT_PLATFORMS);
|
||||
- succ(s) = `platform_success` ∩ sel(至少领到一张的平台);
|
||||
- ② 整单成功率 = #{sel⊆succ 且 sel≠∅} / 发起数;
|
||||
- ③ 点位成功率 = Σ|succ| / Σ|sel|;per_platform[p] = 勾了 p 且成功 / 勾了 p。
|
||||
基数含全部 session(started/completed/failed/abandoned),与「发起数」同基数。
|
||||
"""
|
||||
started = len(rows)
|
||||
full_success = 0
|
||||
point_success = 0
|
||||
point_total = 0
|
||||
per_succ = {p: 0 for p in DEFAULT_PLATFORMS}
|
||||
per_total = {p: 0 for p in DEFAULT_PLATFORMS}
|
||||
for r in rows:
|
||||
sel = set(r.platforms) if r.platforms else set(DEFAULT_PLATFORMS)
|
||||
succ = set(r.platform_success or []) & sel
|
||||
point_success += len(succ)
|
||||
point_total += len(sel)
|
||||
if sel and succ == sel:
|
||||
full_success += 1
|
||||
for p in sel:
|
||||
if p in per_total: # 只统计三档已知平台;未知/非法平台 id 不进 per_platform
|
||||
per_total[p] += 1
|
||||
if p in succ:
|
||||
per_succ[p] += 1
|
||||
return {
|
||||
"full_success_count": full_success,
|
||||
"full_success_rate": round(full_success / started, 4) if started else None,
|
||||
"point_success_count": point_success,
|
||||
"point_total_count": point_total,
|
||||
"point_success_rate": round(point_success / point_total, 4) if point_total else None,
|
||||
"per_platform": {
|
||||
p: (round(per_succ[p] / per_total[p], 4) if per_total[p] else None)
|
||||
for p in DEFAULT_PLATFORMS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
|
||||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||||
return {
|
||||
"id": r.id,
|
||||
@@ -61,6 +105,7 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None) ->
|
||||
"started_at": r.started_at,
|
||||
"claimed_count": r.claimed_count,
|
||||
"trace_url": r.trace_url,
|
||||
"ad_revenue_yuan": ad_revenue_yuan,
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +129,7 @@ def coupon_data_report(
|
||||
date_to: str,
|
||||
user: str | None = None,
|
||||
app_env: str | None = None,
|
||||
statuses: list[str] | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
@@ -93,6 +139,8 @@ def coupon_data_report(
|
||||
|
||||
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
||||
- app_env:prod/dev 精确;None=全部。
|
||||
- statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图
|
||||
(汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。
|
||||
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
@@ -115,6 +163,8 @@ def coupon_data_report(
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponSession.app_env == app_env)
|
||||
if statuses:
|
||||
stmt = stmt.where(CouponSession.status.in_(statuses))
|
||||
if user_ids is not None:
|
||||
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
|
||||
rows = list(db.execute(stmt).scalars())
|
||||
@@ -131,6 +181,7 @@ def coupon_data_report(
|
||||
"p50_ms": _percentile(completed_elapsed, 50),
|
||||
"p95_ms": _percentile(completed_elapsed, 95),
|
||||
"p99_ms": _percentile(completed_elapsed, 99),
|
||||
**_success_rates(rows),
|
||||
}
|
||||
|
||||
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
||||
@@ -197,10 +248,11 @@ def coupon_data_report(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||||
).all()
|
||||
}
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page])
|
||||
items = []
|
||||
for r in page:
|
||||
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
|
||||
items.append(_session_to_row(r, phone, nickname))
|
||||
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
@@ -222,4 +274,57 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
||||
total = db.execute(
|
||||
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
|
||||
).scalar_one()
|
||||
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
|
||||
return {
|
||||
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows],
|
||||
"total": int(total),
|
||||
}
|
||||
|
||||
|
||||
_SLOT_OK = ("success", "already_claimed")
|
||||
_SLOT_TRIED = ("success", "already_claimed", "failed")
|
||||
|
||||
|
||||
def coupon_slot_report(
|
||||
db: Session, *, date_from: str, date_to: str, app_env: str | None = None
|
||||
) -> dict:
|
||||
"""按 coupon_id(具体券)聚合成功率(见 docs/guides/领券成功率指标-设计与埋点.md §13)。
|
||||
|
||||
数据源 coupon_claim_record(粒度=设备-天,唯一键 device+coupon+day)。
|
||||
- 尝试 = status ∈ {success, already_claimed, failed}(skipped 排除);
|
||||
- 成功 = status ∈ {success, already_claimed};成功率 = 成功/尝试;
|
||||
- claim_date 区间 + app_env(None=全部)过滤;按 tried 倒序返回。
|
||||
"""
|
||||
d_from = _date.fromisoformat(date_from)
|
||||
d_to = _date.fromisoformat(date_to)
|
||||
ok = case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)
|
||||
stmt = (
|
||||
select(
|
||||
CouponClaimRecord.coupon_id,
|
||||
func.max(CouponClaimRecord.coupon_name).label("coupon_name"),
|
||||
func.count().label("tried"),
|
||||
func.sum(ok).label("succeeded"),
|
||||
)
|
||||
.where(
|
||||
CouponClaimRecord.claim_date >= d_from,
|
||||
CouponClaimRecord.claim_date <= d_to,
|
||||
CouponClaimRecord.status.in_(_SLOT_TRIED),
|
||||
)
|
||||
.group_by(CouponClaimRecord.coupon_id)
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponClaimRecord.app_env == app_env)
|
||||
items = []
|
||||
for coupon_id, coupon_name, tried, succeeded in db.execute(stmt).all():
|
||||
tried = int(tried or 0)
|
||||
succeeded = int(succeeded or 0)
|
||||
items.append({
|
||||
"coupon_id": coupon_id,
|
||||
"coupon_name": coupon_name,
|
||||
"platform": coupon_id_to_platform(coupon_id),
|
||||
"tried": tried,
|
||||
"succeeded": succeeded,
|
||||
"success_rate": round(succeeded / tried, 4) if tried else None,
|
||||
})
|
||||
items.sort(key=lambda x: (-x["tried"], x["coupon_id"]))
|
||||
return {"items": items}
|
||||
|
||||
@@ -51,7 +51,27 @@ def _yuan_to_cents(v: object) -> int | None:
|
||||
|
||||
def _ts_to_dt(ts: object) -> datetime | None:
|
||||
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
|
||||
if not ts:
|
||||
if ts is None:
|
||||
return None
|
||||
if isinstance(ts, datetime):
|
||||
return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
|
||||
s = str(ts).strip()
|
||||
if not s or s.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
seconds = float(Decimal(s))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
if seconds == 0:
|
||||
return None
|
||||
# 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。
|
||||
if abs(seconds) > 10_000_000_000_000:
|
||||
seconds /= 1_000_000
|
||||
elif abs(seconds) > 10_000_000_000:
|
||||
seconds /= 1_000
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -83,10 +103,6 @@ def _pick(row: dict[str, Any], *keys: str) -> Any:
|
||||
if key in row and row[key] is not None:
|
||||
return row[key]
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||
except (ValueError, OSError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ───────────── 群 ─────────────
|
||||
|
||||
@@ -18,12 +18,20 @@ from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.admin import AdminAuditLog
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.device import DeviceLiveness
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
InviteCashTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
from app.repositories import activity, ad_ecpm
|
||||
|
||||
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
|
||||
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
|
||||
@@ -76,6 +84,43 @@ def offset_paginate(
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def _attach_last_active(db: Session, users: list[User]) -> None:
|
||||
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
|
||||
|
||||
口径同 [_last_active_expr];按本页 user_id 批量两次 GROUP BY,防 N+1。
|
||||
"""
|
||||
uids = [u.id for u in users]
|
||||
if not uids:
|
||||
return
|
||||
ev_map = dict(
|
||||
db.execute(
|
||||
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
|
||||
.where(
|
||||
AnalyticsEvent.user_id.in_(uids),
|
||||
activity.active_event_condition(),
|
||||
)
|
||||
.group_by(AnalyticsEvent.user_id)
|
||||
).all()
|
||||
)
|
||||
eng_map = dict(
|
||||
db.execute(
|
||||
select(CouponPromptEngagement.user_id, func.max(CouponPromptEngagement.created_at))
|
||||
.where(
|
||||
CouponPromptEngagement.user_id.in_(uids),
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
.group_by(CouponPromptEngagement.user_id)
|
||||
).all()
|
||||
)
|
||||
for u in users:
|
||||
candidates = [
|
||||
activity.norm_utc(u.created_at), # baseline 由 last_login_at 改为 created_at(登录不算活跃)
|
||||
activity.norm_utc(ev_map.get(u.id)),
|
||||
activity.norm_utc(eng_map.get(u.id)),
|
||||
]
|
||||
u.last_active_at = max((c for c in candidates if c is not None), default=None)
|
||||
|
||||
|
||||
def list_users(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -87,16 +132,30 @@ def list_users(
|
||||
created_to: datetime | None = None,
|
||||
last_login_from: datetime | None = None,
|
||||
last_login_to: datetime | None = None,
|
||||
last_active_from: datetime | None = None,
|
||||
last_active_to: datetime | None = None,
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[User], int | None, int]:
|
||||
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
|
||||
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
||||
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录·最近活跃
|
||||
时间范围筛选,按 id·注册时间·最近登录·最近活跃排序;每页附带计算列 last_active_at
|
||||
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
||||
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
|
||||
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
|
||||
stmt = select(User)
|
||||
# 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at
|
||||
#(登录不代表在用 App;口径统一到 activity.py,含 home_view + 比价 + 领券,见 activity.ACTIVE_EVENTS)。
|
||||
# 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数。
|
||||
ev_agg, eng_agg = activity.last_active_subqueries(db)
|
||||
last_active = activity.last_active_expr(
|
||||
User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name
|
||||
)
|
||||
stmt = (
|
||||
select(User)
|
||||
.outerjoin(ev_agg, ev_agg.c.user_id == User.id)
|
||||
.outerjoin(eng_agg, eng_agg.c.user_id == User.id)
|
||||
)
|
||||
if phone:
|
||||
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
|
||||
if register_channel:
|
||||
@@ -113,16 +172,25 @@ def list_users(
|
||||
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
|
||||
if last_login_to is not None:
|
||||
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
|
||||
if last_active_from is not None:
|
||||
stmt = stmt.where(last_active >= _as_utc(last_active_from))
|
||||
if last_active_to is not None:
|
||||
stmt = stmt.where(last_active <= _as_utc(last_active_to))
|
||||
|
||||
sort_cols = {
|
||||
"id": User.id,
|
||||
"created_at": User.created_at,
|
||||
"last_login_at": User.last_login_at,
|
||||
"last_active_at": last_active,
|
||||
}
|
||||
sort_col = sort_cols.get(sort_by, User.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
||||
)
|
||||
_attach_last_active(db, items)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
|
||||
@@ -148,11 +216,14 @@ def list_comparison_records(
|
||||
phone: str | None = None,
|
||||
status: str | None = None,
|
||||
business_type: str | None = None,
|
||||
store: str | None = None,
|
||||
product: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[ComparisonRecord], int | None, int]:
|
||||
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
|
||||
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
|
||||
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
|
||||
join User 取 phone/nickname 瞬态挂记录上。"""
|
||||
stmt = select(ComparisonRecord)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(ComparisonRecord.user_id == user_id)
|
||||
@@ -166,12 +237,22 @@ def list_comparison_records(
|
||||
stmt = stmt.where(ComparisonRecord.status == status)
|
||||
if business_type:
|
||||
stmt = stmt.where(ComparisonRecord.business_type == business_type)
|
||||
if store:
|
||||
stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%"))
|
||||
if product:
|
||||
# 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。
|
||||
stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%"))
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt,
|
||||
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
_attach_user_info(db, items)
|
||||
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
|
||||
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
|
||||
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
|
||||
for it in items:
|
||||
it.ad_revenue_yuan = rev.get(it.trace_id, 0.0)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
@@ -726,26 +807,19 @@ def withdraw_risk_flags(
|
||||
return flags, score
|
||||
|
||||
|
||||
def withdraw_ledger_check(db: Session) -> dict:
|
||||
cash_balance_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txn_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
|
||||
)
|
||||
def _check_withdraw_ledger_side(
|
||||
orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str
|
||||
) -> dict:
|
||||
"""对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。
|
||||
|
||||
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
||||
cash_txns = list(
|
||||
db.execute(
|
||||
select(CashTransaction).where(
|
||||
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"}
|
||||
orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。
|
||||
规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水;
|
||||
非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。
|
||||
"""
|
||||
withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz}
|
||||
refund_counts: dict[str, int] = {}
|
||||
for txn in cash_txns:
|
||||
if txn.biz_type == "withdraw_refund" and txn.ref_id:
|
||||
for txn in txns:
|
||||
if txn.biz_type == refund_biz and txn.ref_id:
|
||||
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
|
||||
|
||||
missing_withdraw = 0
|
||||
@@ -760,24 +834,95 @@ def withdraw_ledger_check(db: Session) -> dict:
|
||||
if has_refund and order.status not in {"failed", "rejected"}:
|
||||
refund_on_non_terminal += 1
|
||||
|
||||
duplicate_refund = sum(1 for count in refund_counts.values() if count > 1)
|
||||
diff = cash_balance_total - cash_txn_total
|
||||
return {
|
||||
"missing_withdraw": missing_withdraw,
|
||||
"missing_refund": missing_refund,
|
||||
"duplicate_refund": sum(1 for count in refund_counts.values() if count > 1),
|
||||
"refund_on_non_terminal": refund_on_non_terminal,
|
||||
}
|
||||
|
||||
|
||||
def withdraw_ledger_check(db: Session) -> dict:
|
||||
"""现金账本校验:两本物理隔离的账各自对账(产品红线:coin_cash / invite_cash 不串)。
|
||||
|
||||
普通现金:CoinAccount.cash_balance_cents ↔ cash_transaction(withdraw/withdraw_refund);
|
||||
邀请奖励金:CoinAccount.invite_cash_balance_cents ↔ invite_cash_transaction
|
||||
(invite_withdraw/invite_withdraw_refund)。
|
||||
提现单按 source 分流到对应账核对——邀请提现的流水写在 invite_cash_transaction 表,
|
||||
绝不能拿去和普通现金流水比(否则每笔邀请提现单都会被误报「缺扣款/缺退款流水」)。
|
||||
分流口径与 create_withdraw 一致:仅 source==invite_cash 走邀请账,其余(含历史空值)归普通现金。
|
||||
"""
|
||||
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
||||
coin_orders = [o for o in orders if o.source != "invite_cash"]
|
||||
invite_orders = [o for o in orders if o.source == "invite_cash"]
|
||||
|
||||
# —— 普通现金账(coin_cash) ——
|
||||
cash_balance_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txn_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txns = list(
|
||||
db.execute(
|
||||
select(CashTransaction).where(
|
||||
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
coin = _check_withdraw_ledger_side(
|
||||
coin_orders, cash_txns, withdraw_biz="withdraw", refund_biz="withdraw_refund"
|
||||
)
|
||||
cash_diff = cash_balance_total - cash_txn_total
|
||||
|
||||
# —— 邀请奖励金账(invite_cash,独立账户 + 独立流水表) ——
|
||||
invite_balance_total = int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(CoinAccount.invite_cash_balance_cents), 0))
|
||||
).scalar_one()
|
||||
)
|
||||
invite_txn_total = int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(InviteCashTransaction.amount_cents), 0))
|
||||
).scalar_one()
|
||||
)
|
||||
invite_txns = list(
|
||||
db.execute(
|
||||
select(InviteCashTransaction).where(
|
||||
InviteCashTransaction.biz_type.in_(("invite_withdraw", "invite_withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
invite = _check_withdraw_ledger_side(
|
||||
invite_orders, invite_txns,
|
||||
withdraw_biz="invite_withdraw", refund_biz="invite_withdraw_refund",
|
||||
)
|
||||
invite_diff = invite_balance_total - invite_txn_total
|
||||
|
||||
ok = (
|
||||
diff == 0
|
||||
and missing_withdraw == 0
|
||||
and missing_refund == 0
|
||||
and duplicate_refund == 0
|
||||
and refund_on_non_terminal == 0
|
||||
cash_diff == 0
|
||||
and invite_diff == 0
|
||||
and all(v == 0 for v in coin.values())
|
||||
and all(v == 0 for v in invite.values())
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
# 普通现金账(coin_cash:金币兑换的现金)
|
||||
"cash_balance_total_cents": cash_balance_total,
|
||||
"cash_transaction_total_cents": cash_txn_total,
|
||||
"balance_diff_cents": diff,
|
||||
"missing_withdraw_txn_count": missing_withdraw,
|
||||
"missing_refund_txn_count": missing_refund,
|
||||
"duplicate_refund_txn_count": duplicate_refund,
|
||||
"refund_txn_on_non_terminal_count": refund_on_non_terminal,
|
||||
"balance_diff_cents": cash_diff,
|
||||
"missing_withdraw_txn_count": coin["missing_withdraw"],
|
||||
"missing_refund_txn_count": coin["missing_refund"],
|
||||
"duplicate_refund_txn_count": coin["duplicate_refund"],
|
||||
"refund_txn_on_non_terminal_count": coin["refund_on_non_terminal"],
|
||||
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
|
||||
"invite_cash_balance_total_cents": invite_balance_total,
|
||||
"invite_cash_transaction_total_cents": invite_txn_total,
|
||||
"invite_balance_diff_cents": invite_diff,
|
||||
"invite_missing_withdraw_txn_count": invite["missing_withdraw"],
|
||||
"invite_missing_refund_txn_count": invite["missing_refund"],
|
||||
"invite_duplicate_refund_txn_count": invite["duplicate_refund"],
|
||||
"invite_refund_txn_on_non_terminal_count": invite["refund_on_non_terminal"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
+222
-74
@@ -5,17 +5,23 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.coupon_data import _percentile
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.coupon_state import (
|
||||
CouponClaimRecord,
|
||||
CouponPromptEngagement,
|
||||
CouponSession,
|
||||
)
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.savings import SavingsRecord
|
||||
@@ -24,11 +30,17 @@ 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")
|
||||
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
|
||||
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
|
||||
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
|
||||
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
|
||||
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
|
||||
COUPON_REWARD_BIZ_TYPES = ("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 = (
|
||||
*REWARD_VIDEO_BIZ_TYPES,
|
||||
*COUPON_REWARD_BIZ_TYPES,
|
||||
*COMPARISON_REWARD_BIZ_TYPES,
|
||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||
@@ -55,29 +67,10 @@ def _beijing_today_start_utc() -> datetime:
|
||||
def today_dau(db: Session) -> int:
|
||||
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
|
||||
|
||||
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
|
||||
= period_active_dau(今天, 今天);历史 / 多天窗口用 period_active_dau 传区间(广告收益报表复用)。
|
||||
"""
|
||||
today_bj = datetime.now(_BEIJING).date()
|
||||
today_start = _beijing_today_start_utc()
|
||||
tomorrow_start = today_start + timedelta(days=1)
|
||||
login_user_ids = _id_set(
|
||||
db,
|
||||
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_claim_user_ids = _id_set(
|
||||
db,
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == today_bj,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
),
|
||||
)
|
||||
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
|
||||
return period_active_dau(db, today_bj, today_bj)
|
||||
|
||||
|
||||
def _default_period_end() -> date:
|
||||
@@ -134,6 +127,56 @@ def _event_user_ids(
|
||||
)
|
||||
|
||||
|
||||
def _period_active_user_ids(
|
||||
db: Session,
|
||||
*,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
period_from: date,
|
||||
period_to: date,
|
||||
) -> set[int]:
|
||||
"""区间去重活跃用户 id 集合:登录(last_login_at)+ 开始比价(real_compare_start)+
|
||||
开始领券(real_coupon_start / claim_started)。
|
||||
|
||||
user / analytics_event 用 UTC aware 边界 [start_utc, end_utc);coupon_prompt_engagement
|
||||
的 engage_date 是北京自然日 date 列,用 [period_from, period_to] 闭区间。今日 / 历史 / 多天通用。
|
||||
"""
|
||||
login_user_ids = _id_set(
|
||||
db,
|
||||
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc),
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(db, (COMPARE_START_EVENT,), start_utc, end_utc)
|
||||
coupon_event_user_ids = _event_user_ids(db, (COUPON_START_EVENT,), start_utc, end_utc)
|
||||
coupon_claim_user_ids = _id_set(
|
||||
db,
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date >= period_from,
|
||||
CouponPromptEngagement.engage_date <= period_to,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
),
|
||||
)
|
||||
return login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
|
||||
|
||||
|
||||
def period_active_dau(db: Session, date_from: date, date_to: date) -> int:
|
||||
"""任意北京自然日区间 [date_from, date_to] 的去重活跃用户数。
|
||||
|
||||
与数据大盘 period.users.active 同口径(登录 + 开始比价 + 开始领券);广告收益报表按所选
|
||||
日期区间(含今日)复用,ARPU = 区间预估收益 ÷ 本数。全局口径,不随用户 / 类型 / 场景筛选变化。
|
||||
"""
|
||||
period_from, period_to = _normalize_period(date_from, date_to)
|
||||
start_utc, end_utc, _start_local, _end_local = _period_bounds(period_from, period_to)
|
||||
return len(
|
||||
_period_active_user_ids(
|
||||
db,
|
||||
start_utc=start_utc,
|
||||
end_utc=end_utc,
|
||||
period_from=period_from,
|
||||
period_to=period_to,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _commission_rate_percent(raw: str | None) -> Decimal | None:
|
||||
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。"""
|
||||
if raw is None:
|
||||
@@ -261,31 +304,19 @@ def dashboard_overview(
|
||||
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_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_claim_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_start_user_ids | coupon_event_user_ids | coupon_claim_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
|
||||
period_active_user_ids = _period_active_user_ids(
|
||||
db,
|
||||
start_utc=start_utc,
|
||||
end_utc=end_utc,
|
||||
period_from=period_from,
|
||||
period_to=period_to,
|
||||
)
|
||||
# 留存口径(2026-07-05 产品改):次日留存——窗口内每天 D,取 **D-1 日(前日)新增**用户,
|
||||
# 统计其 D 日活跃(登录/开始比价/开始领券)比例,逐日累加。默认窗口=昨日单天,即
|
||||
# 「前日新增用户的昨日留存」。原口径(窗口内新增∩窗口内活跃)在单日窗口下≈100% 无意义
|
||||
# (注册即登录,当天新增必然当天活跃)。逐日 cohort 在下方 trend 循环内顺带累计。
|
||||
retention_cohort_total = 0
|
||||
retention_retained_total = 0
|
||||
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(
|
||||
@@ -295,33 +326,26 @@ def dashboard_overview(
|
||||
ComparisonRecord.created_at >= day_start_local,
|
||||
ComparisonRecord.created_at < day_end_local,
|
||||
)
|
||||
daily_login_user_ids = _user_id_set(
|
||||
daily_active_user_ids = _period_active_user_ids(
|
||||
db,
|
||||
start_utc=day_start_utc,
|
||||
end_utc=day_end_utc,
|
||||
period_from=cur_date,
|
||||
period_to=cur_date,
|
||||
)
|
||||
# 次日留存:cohort = 前一日(D-1)新增用户,留存 = 其中当日(D)活跃者(口径见上)。
|
||||
cohort_ids = _user_id_set(
|
||||
select(User.id).where(
|
||||
User.last_login_at >= day_start_utc,
|
||||
User.last_login_at < day_end_utc,
|
||||
)
|
||||
)
|
||||
daily_compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == cur_date,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
User.created_at >= day_start_utc - timedelta(days=1),
|
||||
User.created_at < day_end_utc - timedelta(days=1),
|
||||
)
|
||||
)
|
||||
retention_cohort_total += len(cohort_ids)
|
||||
retention_retained_total += len(cohort_ids & daily_active_user_ids)
|
||||
trend_points.append(
|
||||
{
|
||||
"date": cur_date,
|
||||
"active_users": len(
|
||||
daily_login_user_ids
|
||||
| daily_compare_start_user_ids
|
||||
| daily_coupon_event_user_ids
|
||||
| daily_coupon_claim_user_ids
|
||||
),
|
||||
"active_users": len(daily_active_user_ids),
|
||||
"new_users": _count(
|
||||
User,
|
||||
User.created_at >= day_start_utc,
|
||||
@@ -330,6 +354,11 @@ def dashboard_overview(
|
||||
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
|
||||
}
|
||||
)
|
||||
period_retention_rate = (
|
||||
round(retention_retained_total / retention_cohort_total, 4)
|
||||
if retention_cohort_total
|
||||
else None
|
||||
)
|
||||
|
||||
period_coin_conds = (
|
||||
CoinTransaction.created_at >= start_local,
|
||||
@@ -339,7 +368,7 @@ def dashboard_overview(
|
||||
period_reward_video_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
)
|
||||
period_feed_ad_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
@@ -361,15 +390,29 @@ def dashboard_overview(
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.like("task_%"),
|
||||
)
|
||||
# 领券/比价奖励金币 = biz_type 桶(历史空,兜底)+ 该场景信息流广告实发金币
|
||||
# (ad_feed_reward_record.feed_scene,granted;reward_date 是北京日期串,与 period 同自然日窗口)。
|
||||
period_coupon_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
|
||||
) + _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.feed_scene == "coupon",
|
||||
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
|
||||
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
|
||||
)
|
||||
period_comparison_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
|
||||
) + _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.feed_scene == "comparison",
|
||||
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
|
||||
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
|
||||
)
|
||||
period_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
@@ -413,6 +456,98 @@ def dashboard_overview(
|
||||
else None
|
||||
)
|
||||
|
||||
# ===== 领券核心数据(2026-07-05 产品新增)=====
|
||||
# 数据源:coupon_session(一次领券一行,started_date 北京自然日)+ coupon_claim_record
|
||||
# (一券/点位一天一条终态,claim_date 北京自然日)。点位与 session 不按 trace_id 关联——
|
||||
# record_claims 更新路径不覆盖 trace_id(同设备同券同日重跑归第一次的 trace),按
|
||||
# (device_id, 自然日) 桶关联才可靠;同桶多次发起共享同一份点位终态。
|
||||
period_coupon_sessions = db.execute(
|
||||
select(
|
||||
CouponSession.device_id,
|
||||
CouponSession.started_date,
|
||||
CouponSession.status,
|
||||
CouponSession.elapsed_ms,
|
||||
).where(
|
||||
CouponSession.started_date >= period_from,
|
||||
CouponSession.started_date <= period_to,
|
||||
# 只统计正式环境,同「领券数据」页默认口径(防 debug 包调试数据串台;
|
||||
# 命中 ix_coupon_session_date_env)。点位表无 app_env 列,但点位指标只经
|
||||
# 下方 prod session 触达的 (device, 日) 桶进入统计,随之收敛到 prod。
|
||||
CouponSession.app_env == "prod",
|
||||
)
|
||||
).all()
|
||||
coupon_started = len(period_coupon_sessions)
|
||||
coupon_completed_elapsed = sorted(
|
||||
s.elapsed_ms
|
||||
for s in period_coupon_sessions
|
||||
if s.status == "completed" and s.elapsed_ms is not None
|
||||
)
|
||||
# 点位桶:(device, 日) → (点位总数, 成功点位数)。成功口径与「我的」页累计领券一致
|
||||
# (sum_claimed_count,2026-06-15 产品定):success + already_claimed(已领过=持有券)都算成功。
|
||||
point_buckets: dict[tuple[str, date], tuple[int, int]] = {
|
||||
(dev, d): (int(total), int(succ or 0))
|
||||
for dev, d, total, succ in db.execute(
|
||||
select(
|
||||
CouponClaimRecord.device_id,
|
||||
CouponClaimRecord.claim_date,
|
||||
func.count(),
|
||||
func.sum(
|
||||
case(
|
||||
(CouponClaimRecord.status.in_(("success", "already_claimed")), 1),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
)
|
||||
.where(
|
||||
CouponClaimRecord.claim_date >= period_from,
|
||||
# 上界放宽一天:跨零点场次(23:5x 发起)的点位 claim_date 落在发起日+1,
|
||||
# 桶只经下方 session 触达的键参与计数,放宽不会引入无关数据。
|
||||
CouponClaimRecord.claim_date <= period_to + timedelta(days=1),
|
||||
)
|
||||
.group_by(CouponClaimRecord.device_id, CouponClaimRecord.claim_date)
|
||||
).all()
|
||||
}
|
||||
# 全部领成功的次数:completed 且其 (device, 日) 桶内点位全部成功(桶为空不算)。
|
||||
coupon_all_success = 0
|
||||
completed_bucket_totals: list[int] = []
|
||||
session_bucket_keys: set[tuple[str, date]] = set()
|
||||
for s in period_coupon_sessions:
|
||||
key = (s.device_id, s.started_date)
|
||||
if key not in point_buckets:
|
||||
# 跨零点回退:发起日桶不存在(点位终态全部落在次日)时取 (device, 发起日+1)。
|
||||
# 仅在发起日桶完全缺失时回退,避免抢占该设备次日 session 自己的桶。
|
||||
next_key = (s.device_id, s.started_date + timedelta(days=1))
|
||||
if next_key in point_buckets:
|
||||
key = next_key
|
||||
bucket = point_buckets.get(key)
|
||||
if bucket is not None:
|
||||
session_bucket_keys.add(key)
|
||||
if s.status != "completed" or bucket is None:
|
||||
continue
|
||||
total, succ = bucket
|
||||
completed_bucket_totals.append(total)
|
||||
if total > 0 and succ == total:
|
||||
coupon_all_success += 1
|
||||
# 每次发起的应领点位数:取「完成过的领券」实际点位数的众数(done 帧会给所有点位终态,
|
||||
# 完成场的点位数=当前配置的全量点位数;数据自校准,配置改点位数无需改代码)。本期无完成场
|
||||
# 时给不出,点位成功率置空。
|
||||
coupon_points_per_session = (
|
||||
Counter(completed_bucket_totals).most_common(1)[0][0]
|
||||
if completed_bucket_totals
|
||||
else None
|
||||
)
|
||||
# 成功点位数:本期 session 触达过的 (device, 日) 桶内成功点位之和(桶级去重,同桶重试不重复计)。
|
||||
coupon_point_success = sum(point_buckets[k][1] for k in session_bucket_keys)
|
||||
# 点位成功率 = 成功点位数 / (发起数 × 应领点位数):中途退出未跑到的点位不产生记录,
|
||||
# 但发起数×点位数把它们计入分母 → 视为失败,符合产品口径;重试会拉低该率(分母按次数计)。
|
||||
coupon_point_success_rate = (
|
||||
round(
|
||||
min(1.0, coupon_point_success / (coupon_started * coupon_points_per_session)), 4
|
||||
)
|
||||
if coupon_started and coupon_points_per_session
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": _count(User),
|
||||
@@ -428,7 +563,7 @@ def dashboard_overview(
|
||||
"reward_video_coin_total": _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
),
|
||||
"reward_video_watch_count": _count(
|
||||
AdRewardRecord,
|
||||
@@ -476,11 +611,13 @@ def dashboard_overview(
|
||||
"users": {
|
||||
"new": len(period_new_user_ids),
|
||||
"active": len(period_active_user_ids),
|
||||
"retained_new_users": len(period_retained_new_user_ids),
|
||||
"retained_new_users": retention_retained_total,
|
||||
"retention_cohort": retention_cohort_total,
|
||||
"retention_rate": period_retention_rate,
|
||||
"retention_note": (
|
||||
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
|
||||
"开始领券(real_coupon_start/claim_started),按用户去重"
|
||||
"次日留存:窗口内每天取前一日新增用户,统计其当日活跃"
|
||||
"(登录/开始比价/开始领券,按用户去重)比例,逐日累加;"
|
||||
"默认窗口=昨日,即前日新增用户的昨日留存"
|
||||
),
|
||||
},
|
||||
"comparison": {
|
||||
@@ -491,6 +628,17 @@ def dashboard_overview(
|
||||
"average_duration_ms": period_avg_duration_ms,
|
||||
"average_saved_cents": period_avg_saved_cents,
|
||||
},
|
||||
"coupon": {
|
||||
"started": coupon_started,
|
||||
"all_success": coupon_all_success,
|
||||
"success_rate": (
|
||||
round(coupon_all_success / coupon_started, 4) if coupon_started else None
|
||||
),
|
||||
"point_success": coupon_point_success,
|
||||
"points_per_session": coupon_points_per_session,
|
||||
"point_success_rate": coupon_point_success_rate,
|
||||
"median_elapsed_ms": _percentile(coupon_completed_elapsed, 50),
|
||||
},
|
||||
"coins": {
|
||||
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
|
||||
"reward_video_coin_total": period_reward_video_coin_total,
|
||||
|
||||
@@ -91,6 +91,7 @@ def get_ad_revenue_report(
|
||||
daily=[AdRevenueDaily(**d) for d in result["daily"]],
|
||||
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
|
||||
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
|
||||
scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_stats"].items()},
|
||||
dau=result["dau"],
|
||||
total=result["total"],
|
||||
truncated=result["truncated"],
|
||||
|
||||
@@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
|
||||
from app.admin.permissions import CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
|
||||
from app.admin.schemas.auth import AdminOut
|
||||
@@ -23,9 +25,31 @@ def _active_super_count(db: AdminDb) -> int:
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut], summary="管理员列表")
|
||||
def _validate_role(db: AdminDb, role: str) -> None:
|
||||
"""角色必须是 super_admin / custom(自定义) / admin_role 表里已存在的角色,否则 400。"""
|
||||
if role in (SUPER_ADMIN_ROLE, CUSTOM_ROLE):
|
||||
return
|
||||
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
|
||||
if role_repo.get_role(db, role) is None:
|
||||
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
|
||||
|
||||
|
||||
def _clean_override(role: str, pages_override: list[str] | None) -> list[str] | None:
|
||||
"""按最终角色算出该存的 pages_override:custom → 勾选集(过滤非法 key,可空列表);
|
||||
非 custom → None(切回普通角色即清空自定义页)。"""
|
||||
if role == CUSTOM_ROLE:
|
||||
return sanitize_pages(pages_override)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
|
||||
def list_admins(db: AdminDb) -> list[AdminOut]:
|
||||
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
|
||||
out: list[AdminOut] = []
|
||||
for a in admin_repo.list_admins(db):
|
||||
item = AdminOut.model_validate(a)
|
||||
item.password = a.plain_password # 明文:仅本 super_admin 专属路由下发,供权限管理页复看
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("", response_model=AdminOut, summary="创建管理员")
|
||||
@@ -34,12 +58,17 @@ def create_admin(
|
||||
) -> AdminOut:
|
||||
if admin_repo.get_by_username(db, body.username) is not None:
|
||||
raise HTTPException(status_code=409, detail="用户名已存在")
|
||||
_validate_role(db, body.role)
|
||||
override = _clean_override(body.role, body.pages_override)
|
||||
new = admin_repo.create_admin(
|
||||
db, username=body.username, password=body.password, role=body.role
|
||||
db, username=body.username, password=body.password, role=body.role,
|
||||
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
|
||||
pages_override=override,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="admin.create", target_type="admin", target_id=new.id,
|
||||
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
|
||||
detail={"username": new.username, "role": new.role, "pages_override": override},
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return AdminOut.model_validate(new)
|
||||
|
||||
@@ -67,8 +96,12 @@ def update_admin(
|
||||
if demotes_super and _active_super_count(db) <= 1:
|
||||
raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员")
|
||||
|
||||
if body.role is not None:
|
||||
_validate_role(db, body.role)
|
||||
|
||||
changes: dict = {}
|
||||
if body.role is not None and body.role != target.role:
|
||||
role_changed = body.role is not None and body.role != target.role
|
||||
if role_changed:
|
||||
changes["role"] = {"before": target.role, "after": body.role}
|
||||
target.role = body.role
|
||||
if body.status is not None and body.status != target.status:
|
||||
@@ -77,6 +110,18 @@ def update_admin(
|
||||
if body.password is not None:
|
||||
changes["password"] = "reset"
|
||||
target.password_hash = hash_password(body.password)
|
||||
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
|
||||
|
||||
# 自定义可见页:按「最终角色」(target.role,已应用完角色变更)决定该存什么。
|
||||
# - 切到/维持 custom 且传了 pages_override → 用勾选集;切到 custom 没传 → 清成空列表。
|
||||
# - 切回普通角色 → 清空 override(_clean_override 返回 None)。
|
||||
# - 角色没变、只传 pages_override(编辑现有 custom 用户的勾选)→ 也更新。
|
||||
if role_changed or body.pages_override is not None:
|
||||
new_override = _clean_override(target.role, body.pages_override)
|
||||
if new_override != target.pages_override:
|
||||
changes["pages_override"] = {"before": target.pages_override, "after": new_override}
|
||||
target.pages_override = new_override
|
||||
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||
db.commit()
|
||||
@@ -86,3 +131,27 @@ def update_admin(
|
||||
detail=changes, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return AdminOut.model_validate(target)
|
||||
|
||||
|
||||
@router.delete("/{admin_id}", summary="删除管理员(带审计)")
|
||||
def delete_admin(admin_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict:
|
||||
target = admin_repo.get_by_id(db, admin_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="管理员不存在")
|
||||
if admin_id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="不能删除自己")
|
||||
# 防自锁:删掉某个 active super_admin 前,确认后仍至少剩 1 个,否则进「零可用超管」死局。
|
||||
if (
|
||||
target.role == "super_admin"
|
||||
and target.status == "active"
|
||||
and _active_super_count(db) <= 1
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="不能删除最后一个超级管理员")
|
||||
username = target.username
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
write_audit(
|
||||
db, admin, action="admin.delete", target_type="admin", target_id=admin_id,
|
||||
detail={"username": username, "role": target.role}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return {"deleted": True}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""admin 埋点健康度:埋点成功率 / 上报成功率 总览 + 趋势 + 下钻(只读)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import analytics_health as repo
|
||||
from app.admin.schemas.analytics_health import (
|
||||
HealthBreakdownRow,
|
||||
HealthMetrics,
|
||||
HealthTrendPoint,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/analytics-health",
|
||||
tags=["admin-analytics-health"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/overview", response_model=HealthMetrics, summary="两段成功率总览")
|
||||
def overview(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
) -> HealthMetrics:
|
||||
return HealthMetrics(**repo.overview(db, date_from, date_to))
|
||||
|
||||
|
||||
@router.get("/trend", response_model=list[HealthTrendPoint], summary="按北京天趋势")
|
||||
def trend(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
) -> list[HealthTrendPoint]:
|
||||
return [HealthTrendPoint(**p) for p in repo.trend(db, date_from, date_to)]
|
||||
|
||||
|
||||
@router.get("/breakdown", response_model=list[HealthBreakdownRow], summary="按维度下钻")
|
||||
def breakdown(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
dim: Annotated[str, Query(pattern="^(event|app_ver|oem)$")] = "event",
|
||||
) -> list[HealthBreakdownRow]:
|
||||
return [HealthBreakdownRow(**r) for r in repo.breakdown(db, date_from, date_to, dim)]
|
||||
@@ -6,6 +6,8 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.admin.deps import AdminDb, CurrentAdmin
|
||||
from app.admin.permissions import CUSTOM_ROLE, sanitize_pages
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
|
||||
from app.admin.security import create_admin_token
|
||||
@@ -17,6 +19,17 @@ logger = logging.getLogger("shagua.admin.auth")
|
||||
router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
|
||||
|
||||
|
||||
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
|
||||
"""AdminOut + 有效可见页(前端左侧导航按此过滤)。
|
||||
role=="custom" → 用这个人的 pages_override(按人自定义,过滤悬空 key);其余走角色解析。"""
|
||||
out = AdminOut.model_validate(admin)
|
||||
if admin.role == CUSTOM_ROLE:
|
||||
out.pages = sanitize_pages(admin.pages_override)
|
||||
else:
|
||||
out.pages = role_repo.effective_pages_of(db, admin.role)
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=AdminLoginResponse,
|
||||
@@ -39,10 +52,10 @@ def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse:
|
||||
return AdminLoginResponse(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
admin=AdminOut.model_validate(admin),
|
||||
admin=_admin_out_with_pages(admin, db),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AdminOut, summary="当前管理员")
|
||||
def me(admin: CurrentAdmin) -> AdminOut:
|
||||
return AdminOut.model_validate(admin)
|
||||
@router.get("/me", response_model=AdminOut, summary="当前管理员(含有效可见页)")
|
||||
def me(admin: CurrentAdmin, db: AdminDb) -> AdminOut:
|
||||
return _admin_out_with_pages(admin, db)
|
||||
|
||||
@@ -32,12 +32,15 @@ def list_comparison_records(
|
||||
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
|
||||
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = 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,
|
||||
business_type=business_type, store=store, product=product,
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminComparisonListItem.model_validate(r) for r in items],
|
||||
|
||||
@@ -55,9 +55,14 @@ def _item(db, key: str) -> ConfigItemOut:
|
||||
raise HTTPException(status_code=404, detail="未知配置项")
|
||||
|
||||
|
||||
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值")
|
||||
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
|
||||
def list_config(db: AdminDb) -> list[ConfigItemOut]:
|
||||
return [ConfigItemOut(**item) for item in app_config.list_all(db)]
|
||||
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
|
||||
return [
|
||||
ConfigItemOut(**item)
|
||||
for item in app_config.list_all(db)
|
||||
if not CONFIG_DEFS[item["key"]].get("hidden")
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
|
||||
|
||||
@@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import (
|
||||
CouponDataOut,
|
||||
CouponDataRow,
|
||||
CouponDataSummary,
|
||||
CouponSlotRow,
|
||||
CouponSlotsOut,
|
||||
CouponUserRecordsOut,
|
||||
)
|
||||
from app.core.rewards import cn_today
|
||||
@@ -52,6 +54,10 @@ def get_coupon_data(
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
|
||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
||||
status: Annotated[
|
||||
list[str] | None,
|
||||
Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"),
|
||||
] = None,
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
|
||||
] = "day",
|
||||
@@ -73,7 +79,7 @@ def get_coupon_data(
|
||||
env = None if app_env == "all" else app_env
|
||||
result = coupon_data.coupon_data_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||
user=user, app_env=env, granularity=granularity,
|
||||
user=user, app_env=env, statuses=status, granularity=granularity,
|
||||
limit=limit, offset=offset, sort=sort,
|
||||
)
|
||||
return CouponDataOut(
|
||||
@@ -87,6 +93,35 @@ def get_coupon_data(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/coupons",
|
||||
response_model=CouponSlotsOut,
|
||||
summary="按券成功率(coupon_id 粒度;成功/(成功+失败),skipped 排除,设备-天口径)",
|
||||
)
|
||||
def get_coupon_slots(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
||||
) -> CouponSlotsOut:
|
||||
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} 天")
|
||||
env = None if app_env == "all" else app_env
|
||||
result = coupon_data.coupon_slot_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), app_env=env
|
||||
)
|
||||
return CouponSlotsOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
items=[CouponSlotRow(**r) for r in result["items"]],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user-records",
|
||||
response_model=CouponUserRecordsOut,
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
@@ -18,11 +19,17 @@ from app.admin.schemas.ops_marquee_seed import (
|
||||
OpsMarqueeSeedCreate,
|
||||
OpsMarqueeSeedOut,
|
||||
OpsMarqueeSeedUpdate,
|
||||
OpsRealRecordItem,
|
||||
OpsRealRecordsOut,
|
||||
OpsSavingsFeedPreviewOut,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed
|
||||
from app.repositories import ops_marquee
|
||||
from app.repositories import app_config, ops_marquee
|
||||
|
||||
|
||||
class MarqueeModeUpdate(BaseModel):
|
||||
mode: str # mixed / real / seed
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/marquee-seeds",
|
||||
@@ -52,9 +59,58 @@ def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
|
||||
def preview_feed(
|
||||
db: AdminDb,
|
||||
limit: Annotated[int, Query(ge=1, le=30)] = 8,
|
||||
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
|
||||
) -> OpsSavingsFeedPreviewOut:
|
||||
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
|
||||
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
|
||||
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。
|
||||
|
||||
mode 显式指定则预览该模式(**不改持久化配置**,供前端切换开关时实时预览);不传则用当前持久化模式。
|
||||
"""
|
||||
if mode is not None and mode not in ops_marquee.FEED_MODES:
|
||||
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
||||
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode))
|
||||
|
||||
|
||||
@router.get("/real-records", response_model=OpsRealRecordsOut, summary="分页浏览当前模式下可展示的记录(审核用)")
|
||||
def list_real_records(
|
||||
db: AdminDb,
|
||||
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
limit: Annotated[int, Query(ge=1, le=50)] = 8,
|
||||
) -> OpsRealRecordsOut:
|
||||
"""分页列出**当前模式**下可在 app 轮播展示的全部记录(**不去重**):只真实=真实记录;只种子=各启用
|
||||
种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇,固定种子→翻页稳定、能翻遍全部。
|
||||
item.user_id=0 表示种子行。"""
|
||||
if mode is not None and mode not in ops_marquee.FEED_MODES:
|
||||
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
||||
items, total = ops_marquee.list_real_records(db, mode=mode, offset=offset, limit=limit)
|
||||
return OpsRealRecordsOut(items=[OpsRealRecordItem(**it) for it in items], total=total)
|
||||
|
||||
|
||||
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
|
||||
@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)")
|
||||
def get_feed_mode(db: AdminDb) -> dict:
|
||||
"""当前轮播取数模式:mixed=真实优先+种子补位(默认)/ real=只真实 / seed=只种子·合成。"""
|
||||
return {"mode": ops_marquee.get_feed_mode(db)}
|
||||
|
||||
|
||||
@router.patch("/mode", summary="改首页轮播数据源模式(带审计)")
|
||||
def set_feed_mode(
|
||||
body: MarqueeModeUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
if body.mode not in ops_marquee.FEED_MODES:
|
||||
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
||||
before = ops_marquee.get_feed_mode(db)
|
||||
app_config.set_value(db, "marquee_feed_mode", body.mode, admin_id=admin.id, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.set_mode", target_type="config",
|
||||
target_id="marquee_feed_mode", detail={"before": before, "after": body.mode},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return {"mode": body.mode}
|
||||
|
||||
|
||||
@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)")
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""admin RBAC 角色管理(仅 super_admin):列角色 / 权限目录 / 增删改角色。均写审计。
|
||||
|
||||
角色 = 一组「可见页面」(见 app/admin/permissions.py)。角色登录后台后左侧只展示其 pages 对应导航项。
|
||||
只有 super_admin(内建全权角色)能进本组端点(dependencies=require_role() 无参 = 仅 super_admin)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
|
||||
from app.admin.permissions import (
|
||||
PERMISSION_CATALOG,
|
||||
SUPER_ADMIN_ROLE,
|
||||
effective_pages,
|
||||
sanitize_pages,
|
||||
)
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.schemas.role import (
|
||||
PermissionGroup,
|
||||
RoleCreateRequest,
|
||||
RoleOut,
|
||||
RoleUpdateRequest,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.admin_role import AdminRole
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/roles",
|
||||
tags=["admin-roles"],
|
||||
dependencies=[Depends(require_role())], # 无参 = 仅 super_admin
|
||||
)
|
||||
|
||||
|
||||
def _usage_counts(db: AdminDb) -> dict[str, int]:
|
||||
rows = db.execute(
|
||||
select(AdminUser.role, func.count(AdminUser.id)).group_by(AdminUser.role)
|
||||
).all()
|
||||
return {r: c for r, c in rows}
|
||||
|
||||
|
||||
def _to_out(role: AdminRole, usage: dict[str, int]) -> RoleOut:
|
||||
return RoleOut(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
label=role.label or role.name,
|
||||
pages=effective_pages(role.name, role.pages),
|
||||
is_builtin=role.is_builtin,
|
||||
in_use=usage.get(role.name, 0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[RoleOut], summary="角色列表(含可见页 + 使用数)")
|
||||
def list_roles(db: AdminDb) -> list[RoleOut]:
|
||||
usage = _usage_counts(db)
|
||||
return [_to_out(r, usage) for r in role_repo.list_roles(db)]
|
||||
|
||||
|
||||
@router.get("/catalog", response_model=list[PermissionGroup], summary="页面权限目录(分组)")
|
||||
def get_catalog() -> list[PermissionGroup]:
|
||||
return [PermissionGroup(**g) for g in PERMISSION_CATALOG]
|
||||
|
||||
|
||||
@router.post("", response_model=RoleOut, summary="新增角色(带审计)")
|
||||
def create_role(
|
||||
body: RoleCreateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
|
||||
) -> RoleOut:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="角色名称不能为空")
|
||||
if name == SUPER_ADMIN_ROLE:
|
||||
raise HTTPException(status_code=400, detail="super_admin 为内建角色,不能新建")
|
||||
if role_repo.get_role(db, name) is not None:
|
||||
raise HTTPException(status_code=409, detail="角色名称已存在")
|
||||
# 自定义角色:name(key)= label = 输入名称
|
||||
role = role_repo.create_role(db, name=name, label=name, pages=body.pages)
|
||||
write_audit(
|
||||
db, admin, action="role.create", target_type="role", target_id=str(role.id),
|
||||
detail={"name": name, "pages": role.pages}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return _to_out(role, _usage_counts(db))
|
||||
|
||||
|
||||
@router.patch("/{role_id}", response_model=RoleOut, summary="改角色(展示名/可见页,带审计)")
|
||||
def update_role(
|
||||
role_id: int, body: RoleUpdateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
|
||||
) -> RoleOut:
|
||||
role = db.get(AdminRole, role_id)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
if role.is_builtin:
|
||||
raise HTTPException(status_code=400, detail="内建角色「管理员」不可编辑")
|
||||
|
||||
new_label = body.label.strip() if body.label is not None else None
|
||||
changes: dict = {}
|
||||
if new_label and new_label != role.label:
|
||||
changes["label"] = {"before": role.label, "after": new_label}
|
||||
if body.pages is not None:
|
||||
changes["pages"] = {"before": role.pages, "after": sanitize_pages(body.pages)}
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||
|
||||
# 只改展示名 + 可见页;key(name)不可变,故无需级联 admin_user.role
|
||||
role_repo.update_role(db, role, label=new_label, pages=body.pages)
|
||||
write_audit(
|
||||
db, admin, action="role.update", target_type="role", target_id=str(role_id),
|
||||
detail=changes, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return _to_out(role, _usage_counts(db))
|
||||
|
||||
|
||||
@router.delete("/{role_id}", summary="删角色(带审计;内建/在用不可删)")
|
||||
def delete_role(role_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict:
|
||||
role = db.get(AdminRole, role_id)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
if role.is_builtin:
|
||||
raise HTTPException(status_code=400, detail="内建角色不可删除")
|
||||
used = _usage_counts(db).get(role.name, 0)
|
||||
if used > 0:
|
||||
raise HTTPException(status_code=400, detail=f"该角色仍有 {used} 名管理员在用,请先改派再删")
|
||||
name = role.name
|
||||
role_repo.delete_role(db, role)
|
||||
write_audit(
|
||||
db, admin, action="role.delete", target_type="role", target_id=str(role_id),
|
||||
detail={"name": name}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return {"deleted": True}
|
||||
@@ -42,7 +42,12 @@ def list_users(
|
||||
created_to: Annotated[datetime | None, Query()] = None,
|
||||
last_login_from: Annotated[datetime | None, Query()] = None,
|
||||
last_login_to: Annotated[datetime | None, Query()] = None,
|
||||
sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id",
|
||||
# 最近活跃(登录/发起比价/发起领券取最大,见 queries._last_active_expr)筛选与排序
|
||||
last_active_from: Annotated[datetime | None, Query()] = None,
|
||||
last_active_to: Annotated[datetime | None, Query()] = None,
|
||||
sort_by: Annotated[
|
||||
str, Query(pattern="^(id|created_at|last_login_at|last_active_at)$")
|
||||
] = "id",
|
||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
@@ -51,6 +56,7 @@ def list_users(
|
||||
db, phone=phone, register_channel=register_channel, status=status,
|
||||
nickname=nickname, created_from=created_from, created_to=created_to,
|
||||
last_login_from=last_login_from, last_login_to=last_login_to,
|
||||
last_active_from=last_active_from, last_active_to=last_active_to,
|
||||
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
|
||||
@@ -136,9 +136,16 @@ class AdRevenueReportOut(BaseModel):
|
||||
default_factory=dict,
|
||||
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
|
||||
)
|
||||
scene_stats: dict[str, AdRevenueTypeStat] = Field(
|
||||
default_factory=dict,
|
||||
description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};"
|
||||
"全量统计(不受分页截断),供数据大盘「领券广告 / 比价广告」卡;feed_scene 为空的事件不计入",
|
||||
)
|
||||
dau: int | None = Field(
|
||||
None,
|
||||
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
|
||||
description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + "
|
||||
"开始领券)。按 date_from~date_to 区间统计,含今日、近 7 天、近 30 天等任意区间;全局口径,"
|
||||
"不随 user_id / ad_type / feed_scene / app_env 筛选变化",
|
||||
)
|
||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||
|
||||
@@ -6,21 +6,25 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
_Role = Literal["super_admin", "finance", "operator"]
|
||||
# 角色不再硬编码枚举:改为任意角色名(自定义角色由 admin_role 表管理),存在性在路由层校验。
|
||||
|
||||
|
||||
class AdminCreateRequest(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=64)
|
||||
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
|
||||
role: _Role = "operator"
|
||||
role: str = Field("operator", min_length=1, max_length=32)
|
||||
# 仅当 role == "custom":这个人专属可见页 key 列表(逐页勾选结果)。其余角色不传/忽略。
|
||||
pages_override: list[str] | None = None
|
||||
|
||||
|
||||
class AdminUpdateRequest(BaseModel):
|
||||
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
|
||||
"""改角色 / 启用禁用 / 重置密码 / 自定义可见页,字段都可选(只改传了的)。"""
|
||||
|
||||
role: _Role | None = None
|
||||
role: str | None = Field(None, min_length=1, max_length=32)
|
||||
status: Literal["active", "disabled"] | None = None
|
||||
password: str | None = Field(None, min_length=8, max_length=72)
|
||||
# 改成/更新「自定义」可见页;role 切回普通角色时后端会清空 override(见路由)。
|
||||
pages_override: list[str] | None = None
|
||||
|
||||
|
||||
class AdminAuditLogOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""埋点健康度 admin 响应 schema。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HealthMetrics(BaseModel):
|
||||
attempted: int
|
||||
drop_capture: int
|
||||
delivered: int
|
||||
drop_undelivered: int
|
||||
track_success_rate: float | None
|
||||
report_success_rate: float | None
|
||||
|
||||
|
||||
class HealthTrendPoint(HealthMetrics):
|
||||
day: str
|
||||
|
||||
|
||||
class HealthBreakdownRow(HealthMetrics):
|
||||
key: str
|
||||
@@ -20,6 +20,15 @@ class AdminOut(BaseModel):
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None = None
|
||||
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
|
||||
# 前端据此过滤左侧导航(super_admin = 全部页;role=="custom" = pages_override)。见 permissions.py。
|
||||
pages: list[str] = []
|
||||
# 「自定义」可见页原始勾选集(role=="custom" 时非空);列表接口下发,供权限管理页编辑回填勾选。
|
||||
# 普通角色为 None。from_attributes 自动从 ORM 列取。
|
||||
pages_override: list[str] | None = None
|
||||
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
|
||||
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class AdminLoginResponse(BaseModel):
|
||||
|
||||
@@ -23,6 +23,7 @@ class AdminComparisonListItem(BaseModel):
|
||||
status: str
|
||||
information: str | None = None
|
||||
store_name: str | None = None
|
||||
product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索)
|
||||
source_platform_name: str | None = None
|
||||
best_platform_name: str | None = None
|
||||
source_price_cents: int | None = None
|
||||
@@ -35,11 +36,14 @@ class AdminComparisonListItem(BaseModel):
|
||||
retry_count: int | None = None
|
||||
input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生)
|
||||
output_tokens: int | None = None # Σ usage.completion_tokens(server 派生)
|
||||
# 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。
|
||||
llm_cost_yuan: float | 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
|
||||
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -70,3 +74,5 @@ class AdminComparisonDetail(AdminComparisonListItem):
|
||||
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
|
||||
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
|
||||
raw_payload: dict | None = None
|
||||
# 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。见 services/llm_cost.py。
|
||||
llm_price_snapshot: dict | None = None
|
||||
|
||||
@@ -19,6 +19,16 @@ class CouponDataSummary(BaseModel):
|
||||
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
|
||||
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
|
||||
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
|
||||
# 平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md):基数含全部 session。
|
||||
full_success_count: int = Field(0, description="整单成功数(勾选平台全部领到的 session 数)")
|
||||
full_success_rate: float | None = Field(None, description="整单成功率②=整单成功数/发起数;无数据为空")
|
||||
point_success_count: int = Field(0, description="成功平台点位数(Σ 每次成功的平台数)")
|
||||
point_total_count: int = Field(0, description="总平台点位数(Σ 每次勾选平台数;空勾选=全领三档)")
|
||||
point_success_rate: float | None = Field(None, description="点位成功率③=成功点位/总点位;无数据为空")
|
||||
per_platform: dict[str, float | None] = Field(
|
||||
default_factory=dict,
|
||||
description="分平台点位成功率 {平台id: rate|None};恒含美团/淘宝/京东三档,区间内无人勾选的平台为 None",
|
||||
)
|
||||
|
||||
|
||||
class CouponDataDaily(BaseModel):
|
||||
@@ -60,6 +70,9 @@ class CouponDataRow(BaseModel):
|
||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||
claimed_count: int | None = None
|
||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
||||
ad_revenue_yuan: float = Field(
|
||||
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
|
||||
)
|
||||
|
||||
|
||||
class CouponDataOut(BaseModel):
|
||||
@@ -81,3 +94,22 @@ class CouponUserRecordsOut(BaseModel):
|
||||
|
||||
items: list[CouponDataRow]
|
||||
total: int
|
||||
|
||||
|
||||
class CouponSlotRow(BaseModel):
|
||||
"""按券成功率一行(§13):粒度=设备-天;成功率=成功/(成功+失败),skipped 排除。"""
|
||||
|
||||
coupon_id: str
|
||||
coupon_name: str | None = None
|
||||
platform: str | None = Field(None, description="美团/淘宝/京东 平台 id;无法识别为空")
|
||||
tried: int = Field(..., description="尝试数(success+already_claimed+failed 的设备-天数)")
|
||||
succeeded: int = Field(..., description="成功数(success+already_claimed)")
|
||||
success_rate: float | None = Field(None, description="成功率=成功/尝试")
|
||||
|
||||
|
||||
class CouponSlotsOut(BaseModel):
|
||||
"""按券成功率表响应(§13)。"""
|
||||
|
||||
date_from: str
|
||||
date_to: str
|
||||
items: list[CouponSlotRow]
|
||||
|
||||
@@ -43,7 +43,10 @@ class DashboardComparison(BaseModel):
|
||||
class DashboardPeriodUsers(BaseModel):
|
||||
new: int
|
||||
active: int
|
||||
# 次日留存(2026-07-05 起):retained_new_users = 窗口内逐日「前一日新增且当日活跃」用户数之和,
|
||||
# retention_cohort = 对应的前一日新增基数之和,retention_rate = 两者之比。
|
||||
retained_new_users: int
|
||||
retention_cohort: int = 0
|
||||
retention_rate: float | None = None
|
||||
retention_note: str
|
||||
|
||||
@@ -57,6 +60,24 @@ class DashboardPeriodComparison(BaseModel):
|
||||
average_saved_cents: int | None = None
|
||||
|
||||
|
||||
class DashboardPeriodCoupon(BaseModel):
|
||||
"""领券核心数据(2026-07-05 产品新增)。点位=一张券(coupon_claim_record 一天一条终态);
|
||||
成功口径 success+already_claimed(与「我的」页累计领券一致)。"""
|
||||
|
||||
started: int = 0
|
||||
# 全部领成功的次数:completed 且当日该设备全部点位成功
|
||||
all_success: int = 0
|
||||
success_rate: float | None = None
|
||||
# 本期 session 触达的点位中成功的条数(同设备同日去重)
|
||||
point_success: int = 0
|
||||
# 每次发起的应领点位数(本期完成场实际点位数的众数;无完成场为空)
|
||||
points_per_session: int | None = None
|
||||
# 点位成功率 = point_success / (started × points_per_session);未跑到的点位计入分母视为失败
|
||||
point_success_rate: float | None = None
|
||||
# 耗时中位数(仅 completed 的 elapsed_ms,同「领券数据」页口径)
|
||||
median_elapsed_ms: int | None = None
|
||||
|
||||
|
||||
class DashboardPeriodCoins(BaseModel):
|
||||
granted_total: int
|
||||
reward_video_coin_total: int = 0
|
||||
@@ -85,6 +106,7 @@ class DashboardPeriod(BaseModel):
|
||||
date_to: date
|
||||
users: DashboardPeriodUsers
|
||||
comparison: DashboardPeriodComparison
|
||||
coupon: DashboardPeriodCoupon = DashboardPeriodCoupon()
|
||||
coins: DashboardPeriodCoins
|
||||
cash: DashboardPeriodCash
|
||||
trend: list[DashboardTrendPoint] = []
|
||||
|
||||
@@ -62,3 +62,16 @@ class OpsSavingsFeedPreviewItem(BaseModel):
|
||||
class OpsSavingsFeedPreviewOut(BaseModel):
|
||||
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
|
||||
items: list[OpsSavingsFeedPreviewItem]
|
||||
|
||||
|
||||
class OpsRealRecordItem(BaseModel):
|
||||
masked_user: str # 脱敏后展示名(与 app 一致)
|
||||
saved_amount_cents: int # 节省金额(分)
|
||||
created_at: str # 比价记录时间(YYYY-MM-DD HH:MM)
|
||||
user_id: int # 真实 user_id(供运营核对,不下发客户端)
|
||||
|
||||
|
||||
class OpsRealRecordsOut(BaseModel):
|
||||
"""分页浏览「全部可展示的真实记录」(success+省>0,不去重、稳定顺序)。"""
|
||||
items: list[OpsRealRecordItem]
|
||||
total: int # 满足条件的真实记录总数(算页数用)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""admin RBAC 角色 + 权限目录 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PermissionPage(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
|
||||
|
||||
class PermissionGroup(BaseModel):
|
||||
group: str
|
||||
pages: list[PermissionPage]
|
||||
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
id: int
|
||||
name: str # 角色 key(承重,不可变)
|
||||
label: str # 展示名(UI 显示)
|
||||
pages: list[str] # 有效可见页 key(super_admin = 全部页)
|
||||
is_builtin: bool # 内建角色(super_admin):不可编辑/删除
|
||||
in_use: int # 使用该角色的管理员数(删除前端提示 / 拦截用)
|
||||
|
||||
|
||||
class RoleCreateRequest(BaseModel):
|
||||
# 新增自定义角色:name 即用户输入的名称(同时作 key 与展示名)
|
||||
name: str = Field(..., min_length=1, max_length=32)
|
||||
pages: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RoleUpdateRequest(BaseModel):
|
||||
# 只改展示名 + 可见页;key 不可变
|
||||
label: str | None = Field(None, min_length=1, max_length=32)
|
||||
pages: list[str] | None = None
|
||||
@@ -20,6 +20,9 @@ class AdminUserListItem(BaseModel):
|
||||
wechat_nickname: str | None = None
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
# 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);列表页由 queries._attach_last_active
|
||||
# 瞬态挂上。其他复用本 schema 的入口(用户 360 等)没挂该属性 → None(前端显示 '-')。
|
||||
last_active_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminUserOverview(BaseModel):
|
||||
|
||||
@@ -130,6 +130,7 @@ class WithdrawBulkResult(BaseModel):
|
||||
|
||||
class WithdrawLedgerCheckOut(BaseModel):
|
||||
ok: bool
|
||||
# 普通现金账(coin_cash:金币兑换的现金)
|
||||
cash_balance_total_cents: int
|
||||
cash_transaction_total_cents: int
|
||||
balance_diff_cents: int
|
||||
@@ -137,6 +138,14 @@ class WithdrawLedgerCheckOut(BaseModel):
|
||||
missing_refund_txn_count: int
|
||||
duplicate_refund_txn_count: int
|
||||
refund_txn_on_non_terminal_count: int
|
||||
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)。默认 0 向后兼容。
|
||||
invite_cash_balance_total_cents: int = 0
|
||||
invite_cash_transaction_total_cents: int = 0
|
||||
invite_balance_diff_cents: int = 0
|
||||
invite_missing_withdraw_txn_count: int = 0
|
||||
invite_missing_refund_txn_count: int = 0
|
||||
invite_duplicate_refund_txn_count: int = 0
|
||||
invite_refund_txn_on_non_terminal_count: int = 0
|
||||
|
||||
|
||||
class WxpayHealthCheckOut(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user