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
+27
View File
@@ -0,0 +1,27 @@
"""ad_ecpm_record add feed_scene
Revision ID: ad_ecpm_feed_scene
Revises: comparison_record_total_ms
Create Date: 2026-06-27 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "ad_ecpm_feed_scene"
down_revision: str | Sequence[str] | None = "comparison_record_total_ms"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("ad_ecpm_record", schema=None) as batch_op:
batch_op.add_column(sa.Column("feed_scene", sa.String(length=16), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("ad_ecpm_record", schema=None) as batch_op:
batch_op.drop_column("feed_scene")
@@ -0,0 +1,27 @@
"""comparison_record add total_ms
Revision ID: comparison_record_total_ms
Revises: feedback_review_fields
Create Date: 2026-06-27 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "comparison_record_total_ms"
down_revision: str | Sequence[str] | None = "feedback_review_fields"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.add_column(sa.Column("total_ms", sa.Integer(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.drop_column("total_ms")
+53
View File
@@ -0,0 +1,53 @@
"""cps_order table for dashboard meituan cps
Revision ID: cps_order_dashboard
Revises: ad_ecpm_feed_scene
Create Date: 2026-06-27 12:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "cps_order_dashboard"
down_revision: str | Sequence[str] | None = "ad_ecpm_feed_scene"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"cps_order",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("order_id", sa.String(length=64), nullable=False),
sa.Column("sid", sa.String(length=64), nullable=True),
sa.Column("act_id", sa.String(length=64), nullable=True),
sa.Column("biz_line", sa.Integer(), nullable=True),
sa.Column("trade_type", sa.Integer(), nullable=True),
sa.Column("pay_price_cents", sa.Integer(), nullable=True),
sa.Column("commission_cents", sa.Integer(), nullable=True),
sa.Column("commission_rate", sa.String(length=16), nullable=True),
sa.Column("refund_price_cents", sa.Integer(), nullable=True),
sa.Column("refund_profit_cents", sa.Integer(), nullable=True),
sa.Column("mt_status", sa.String(length=8), nullable=True),
sa.Column("invalid_reason", sa.String(length=128), nullable=True),
sa.Column("product_name", sa.String(length=512), nullable=True),
sa.Column("pay_time", sa.DateTime(timezone=True), nullable=True),
sa.Column("mt_update_time", sa.DateTime(timezone=True), nullable=True),
sa.Column("raw", sa.JSON(), nullable=False),
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("order_id", name="uq_cps_order_order_id"),
)
op.create_index("ix_cps_order_order_id", "cps_order", ["order_id"])
op.create_index("ix_cps_order_sid", "cps_order", ["sid"])
op.create_index("ix_cps_order_act_id", "cps_order", ["act_id"])
op.create_index("ix_cps_order_mt_status", "cps_order", ["mt_status"])
op.create_index("ix_cps_order_pay_time", "cps_order", ["pay_time"])
def downgrade() -> None:
op.drop_table("cps_order")
@@ -0,0 +1,78 @@
"""feedback review fields
Revision ID: feedback_review_fields
Revises: coupon_daily_completion
Create Date: 2026-06-22 20:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_review_fields"
down_revision: str | Sequence[str] | None = "coupon_daily_completion"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("feedback", schema=None) as batch_op:
batch_op.add_column(sa.Column("reject_reason", sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column("reward_coins", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("review_note", sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column("reviewed_by_admin_id", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
batch_op.create_index(batch_op.f("ix_feedback_status"), ["status"], unique=False)
batch_op.create_foreign_key(
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
"admin_user",
["reviewed_by_admin_id"],
["id"],
)
feedback = sa.table(
"feedback",
sa.column("status", sa.String(length=16)),
)
op.execute(
feedback.update()
.where(feedback.c.status == "new")
.values(status="pending")
)
op.execute(
feedback.update()
.where(feedback.c.status == "handled")
.values(status="adopted")
)
def downgrade() -> None:
feedback = sa.table(
"feedback",
sa.column("status", sa.String(length=16)),
)
op.execute(
feedback.update()
.where(feedback.c.status == "pending")
.values(status="new")
)
op.execute(
feedback.update()
.where(feedback.c.status == "adopted")
.values(status="handled")
)
with op.batch_alter_table("feedback", schema=None) as batch_op:
batch_op.drop_constraint(
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
type_="foreignkey",
)
batch_op.drop_index(batch_op.f("ix_feedback_status"))
batch_op.drop_column("reviewed_at")
batch_op.drop_column("reviewed_by_admin_id")
batch_op.drop_column("review_note")
batch_op.drop_column("reward_coins")
batch_op.drop_column("reject_reason")
+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="运营内部审核备注")
+4 -3
View File
@@ -241,11 +241,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
db, user.id,
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id,
adn=payload.adn, slot_id=payload.slot_id, feed_scene=payload.feed_scene,
)
logger.info(
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id,
payload.ecpm, payload.adn, payload.slot_id,
)
return EcpmReportOut(ok=True)
+59 -6
View File
@@ -1,7 +1,8 @@
"""帮助与反馈 endpoint。
路由前缀 `/api/v1/feedback`, Bearer 鉴权(反馈绑到登录用户,便于回访)
POST / 提交反馈(multipart:content / contact 必填,images 可选 4 )
POST / 提交反馈(multipart:content,images 可选 4 )
GET /records 我的反馈历史(pending/adopted/rejected)
截图复用 [app.core.media] 落盘到 /media/feedback/
"""
@@ -9,12 +10,18 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import feedback as feedback_repo
from app.schemas.feedback import FeedbackOut
from app.schemas.feedback import (
FeedbackConfigOut,
FeedbackOut,
FeedbackRecordCountsOut,
FeedbackRecordOut,
FeedbackRecordsOut,
)
logger = logging.getLogger("shagua.feedback")
@@ -23,6 +30,52 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 4
_CONTENT_MAX = 2000
_CONTACT_MAX = 128
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
def _app_status(db_status: str) -> str:
return {
"new": "pending",
"handled": "adopted",
"approved": "adopted",
}.get(db_status, db_status)
def _record_out(fb) -> FeedbackRecordOut:
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
images=fb.images or [],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
created_at=fb.created_at,
)
@router.get("/config", response_model=FeedbackConfigOut, summary="反馈页配置")
def feedback_config(_: CurrentUser) -> FeedbackConfigOut:
return FeedbackConfigOut()
@router.get("/records", response_model=FeedbackRecordsOut, summary="我的反馈历史")
def feedback_records(
user: CurrentUser,
db: DbSession,
status: str | None = Query(default=None),
) -> FeedbackRecordsOut:
if status is not None and status not in _VALID_RECORD_STATUS:
raise HTTPException(status_code=400, detail="invalid status")
all_records = [_record_out(fb) for fb in feedback_repo.list_feedback(db, user_id=user.id)]
counts = FeedbackRecordCountsOut(
all=len(all_records),
pending=sum(1 for r in all_records if r.status == "pending"),
adopted=sum(1 for r in all_records if r.status == "adopted"),
rejected=sum(1 for r in all_records if r.status == "rejected"),
)
records = [r for r in all_records if r.status == status] if status else all_records
return FeedbackRecordsOut(records=records, counts=counts)
@router.post("", response_model=FeedbackOut, summary="提交反馈")
@@ -30,7 +83,9 @@ async def submit_feedback(
user: CurrentUser,
db: DbSession,
content: str = Form(...),
contact: str = Form(...),
# 原型改版后 App 已去掉"联系方式"字段,故 contact 改为可选(默认空串);
# 仍兼容旧端/后台带 contact 的提交。DB 列为 NOT NULL,空串即可满足。
contact: str = Form(default=""),
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
@@ -39,8 +94,6 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容不能为空")
if len(content) > _CONTENT_MAX:
raise HTTPException(status_code=400, detail="反馈内容过长")
if not contact:
raise HTTPException(status_code=400, detail="联系方式不能为空")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")
+4 -4
View File
@@ -2,14 +2,14 @@
路由前缀 /api/v1/invite, Bearer 鉴权(用户级数据);唯一例外是 /landing-track
(落地页 dl.html 浏览器访问 token,详见任务 3 [[invite-three-tasks]]):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
GET /me 我的邀请码 + 分享链接 + 已邀人数(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码;支持三种归因:
clipboard:首启读剪贴板拿邀请码 上报码
manual:用户在邀请页输入邀请码 上报码
fingerprint:剪贴板没拿到时上报指纹,后端反查
POST /landing-track 落地页 dl.html 访问时上报指纹(剪贴板兜底的服务端一端,无需鉴权)
绑定的真正逻辑(邀请码生成/反查幂等自邀屏蔽发金币指纹反查)
绑定的真正逻辑(邀请码生成/反查幂等自邀屏蔽指纹反查)
repositories/invite.py
"""
from __future__ import annotations
@@ -38,7 +38,7 @@ logger = logging.getLogger("shagua.invite")
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
_BIND_MESSAGES = {
"success": "邀请绑定成功,金币已到账",
"success": "邀请绑定成功",
"already_bound": "你已绑定过邀请人",
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
@@ -154,7 +154,7 @@ def landing_track(
return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,不发邀请金币)")
def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut:
+3
View File
@@ -87,5 +87,8 @@ def setup_logging(debug: bool = False) -> None:
# 第三方库降噪
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
# watchfiles(uvicorn --reload 用)的 DEBUG 会把每次文件变更打进日志文件,
# 在 Windows 多进程轮转失败时会自我放大刷屏 → 压到 WARNING。
logging.getLogger("watchfiles").setLevel(logging.WARNING)
_CONFIGURED = True
+5 -5
View File
@@ -82,12 +82,12 @@ def record_milestone_reward(milestone: int) -> int:
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
PRICE_REPORT_REWARD_COINS: int = 1000
# ===== 意见反馈采纳奖励(人工审核按质量发放)=====
# 后台审核反馈时允许发放的单条金币上限。只做后端硬保护,具体档位由 admin-web 呈现。
FEEDBACK_REWARD_MAX_COINS: int = 10000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
INVITE_INVITER_COINS: int = 10000
INVITE_INVITEE_COINS: int = 10000
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
# "新用户闸":被邀请人必须在注册后此窗口内绑定才生效(挡存量老用户互相填码刷关系)。
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72
+53 -3
View File
@@ -77,9 +77,10 @@ def _call(path: str, body_obj: dict[str, Any]) -> dict[str, Any]:
raise MeituanCpsError(f"meituan http {resp.status_code}")
data = resp.json()
if data.get("code") != 0:
logger.error("[MT] api error code=%s message=%s", data.get("code"), data.get("message"))
raise MeituanCpsError(f"code={data.get('code')} {data.get('message')}")
if str(data.get("code")) != "0":
message = data.get("message") or data.get("msg")
logger.error("[MT] api error code=%s message=%s", data.get("code"), message)
raise MeituanCpsError(f"code={data.get('code')} {message}")
return data
@@ -142,3 +143,52 @@ def get_referral_link(
body["linkTypeList"] = link_type_list or [1, 3]
return _call("/cps_open/common/api/v1/get_referral_link", body)
def query_order(
*,
start_time: int,
end_time: int,
query_time_type: int = 1,
page: int = 1,
limit: int = 100,
platform: int | None = None,
business_line: list[int] | None = None,
act_id: int | str | None = None,
sid: str | None = None,
order_id: str | None = None,
trade_type: int | None = None,
search_type: int = 1,
scroll_id: str | None = None,
) -> dict[str, Any]:
"""按时间窗拉 CPS 订单做对账。
美团接口要求 startTime/endTime 10 位秒级时间戳;commissionRate 返回
"300" 表示 3%,订单金额/佣金是元字符串
"""
body: dict[str, Any] = {
"queryTimeType": query_time_type,
"startTime": start_time,
"endTime": end_time,
"limit": min(max(limit, 1), 100),
"searchType": search_type,
}
if search_type == 2:
body["page"] = 1
if scroll_id:
body["scrollId"] = scroll_id
else:
body["page"] = max(page, 1)
if platform is not None:
body["platform"] = platform
if business_line:
body["businessLine"] = business_line
if act_id is not None:
body["actId"] = act_id
if sid:
body["sid"] = sid
if order_id:
body["orderId"] = order_id
if trade_type is not None:
body["tradeType"] = trade_type
return _call("/cps_open/common/api/v1/query_order", body)
+1
View File
@@ -12,6 +12,7 @@ from app.models.coupon_state import ( # noqa: F401
CouponDailyCompletion,
CouponPromptEngagement,
)
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
+2
View File
@@ -29,6 +29,8 @@ class AdEcpmRecord(Base):
)
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
ad_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 点位场景:comparison(比价) / coupon(领券) / welfare(福利),用于拆分信息流/Draw 收益。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
+4
View File
@@ -97,6 +97,10 @@ class ComparisonRecord(Base):
# 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底
raw_payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
# ===== 性能统计(debug 用)=====
# 客户端整场比价墙钟耗时(ms)。旧记录/旧客户端未上报时为 None。
total_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
+50
View File
@@ -0,0 +1,50 @@
"""CPS 对账订单(cps_order)。
从美团联盟 query_order 按时间窗拉回的订单明细大盘只读本表做美团 CPS
收入汇总,不在页面加载时实时请求第三方接口
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class CpsOrder(Base):
__tablename__ = "cps_order"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
biz_line: Mapped[int | None] = mapped_column(Integer, nullable=True)
trade_type: Mapped[int | None] = mapped_column(Integer, nullable=True)
pay_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
commission_rate: Mapped[str | None] = mapped_column(String(16), nullable=True)
refund_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
refund_profit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 美团订单状态:2 付款 / 3 完成 / 4 取消 / 5 风控 / 6 结算。
mt_status: Mapped[str | None] = mapped_column(String(8), index=True, nullable=True)
invalid_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
product_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
pay_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
)
mt_update_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
raw: Mapped[dict] = mapped_column(
JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=dict
)
first_seen: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
+14 -4
View File
@@ -1,7 +1,7 @@
"""用户反馈表(帮助与反馈)。
每条 = 用户一次提交content 必填,contact 必填(微信/QQ/手机,便于回访),images 为可选的
截图 URL 列表(/media/feedback/...,JSON )status: new(待处理)/ handled(已处理)
每条 = 用户一次提交content 必填,contact 兼容旧端可为空串,images 为可选的
截图 URL 列表(/media/feedback/...,JSON )status: pending/adopted/rejected
"""
from __future__ import annotations
@@ -24,8 +24,18 @@ class Feedback(Base):
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# new(待处理) / handled(已处理)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
reviewed_by_admin_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("admin_user.id"), nullable=True
)
reviewed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
+2
View File
@@ -23,6 +23,7 @@ def create_ecpm_record(
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
feed_scene: str | None = None,
) -> AdEcpmRecord:
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。"""
if ad_session_id:
@@ -35,6 +36,7 @@ def create_ecpm_record(
ad_session_id=ad_session_id,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
ecpm_raw=ecpm_raw,
report_date=cn_today().isoformat(),
)
+1
View File
@@ -85,6 +85,7 @@ def upsert_record(
information=payload.information,
best_deeplink=payload.best_deeplink,
trace_url=payload.trace_url,
total_ms=payload.total_ms,
total_dish_count=payload.total_dish_count,
skipped_dish_count=payload.skipped_dish_count,
items=[it.model_dump(exclude_none=True) for it in payload.items],
+16 -2
View File
@@ -1,8 +1,12 @@
"""feedback 表写"""
"""feedback 表写。"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.feedback import Feedback
@@ -19,9 +23,19 @@ def create_feedback(
content=content,
contact=contact,
images=images or None,
status="new",
status="pending",
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
)
db.add(fb)
db.commit()
db.refresh(fb)
return fb
def list_feedback(db: Session, *, user_id: int) -> list[Feedback]:
stmt = (
select(Feedback)
.where(Feedback.user_id == user_id)
.order_by(Feedback.created_at.desc(), Feedback.id.desc())
)
return list(db.execute(stmt).scalars().all())
+11 -24
View File
@@ -1,13 +1,11 @@
"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。
"""好友邀请 CRUD(注册即生效,不再发邀请金币)。
防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
防重复绑定:
1. invitee_user_id 唯一 一个被邀请人只能被绑定一次(幂等键)
2. 自邀屏蔽 inviter == invitee 直接拒
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模
发金币复用 wallet.grant_coins(grant flush commit),与建关系记录在**同一事务**
commit,保证"建关系 + 双方加金币"原子奖励额 = rewards.INVITE_INVITER_COINS /
INVITE_INVITEE_COINS
邀请奖励金币已下线:只记录邀请关系,不再写邀请金币流水
"""
from __future__ import annotations
@@ -23,7 +21,6 @@ from app.core import rewards
from app.models.invite import InviteRelation
from app.models.invite_fingerprint import InviteFingerprint
from app.models.user import User
from app.repositories import wallet as crud_wallet
# 邀请码字符集:去掉易混字符(0/O/1/I/L/B/8/S/5/Z/2),用户口述/手输不易错
_CODE_ALPHABET = "ACDEFGHJKMNPQRTUVWXY34679"
@@ -90,17 +87,17 @@ def _is_new_user(user: User) -> bool:
class BindResult:
status: str # success / already_bound / invalid_code / self_invite / not_eligible
relation: InviteRelation | None = None
invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0)
invitee_coin: int = 0 # 邀请奖励已下线,success 时也返回 0
def bind(
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
) -> BindResult:
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效。
幂等:invitee 已被绑过 already_bound(不重复发奖)
幂等:invitee 已被绑过 already_bound
"""
# 幂等:已绑过直接返回(不重复发奖)
# 幂等:已绑过直接返回
existing = _relation_of_invitee(db, invitee.id)
if existing is not None:
return BindResult("already_bound", existing)
@@ -110,12 +107,12 @@ def bind(
return BindResult("invalid_code")
if inviter.id == invitee.id:
return BindResult("self_invite")
# 新用户闸:被邀请人必须是"新注册"(窗口内)才发奖,挡存量老用户互相填码薅羊毛
# 新用户闸:被邀请人必须是"新注册"(窗口内)才绑定,挡存量老用户互相填码刷关系
if not _is_new_user(invitee):
return BindResult("not_eligible")
inviter_coin = rewards.INVITE_INVITER_COINS
invitee_coin = rewards.INVITE_INVITEE_COINS
inviter_coin = 0
invitee_coin = 0
rel = InviteRelation(
inviter_user_id=inviter.id,
@@ -126,15 +123,6 @@ def bind(
invitee_coin=invitee_coin,
)
db.add(rel)
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账。
crud_wallet.grant_coins(
db, inviter.id, inviter_coin,
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
)
crud_wallet.grant_coins(
db, invitee.id, invitee_coin,
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
)
try:
db.commit()
except IntegrityError:
@@ -145,8 +133,7 @@ def bind(
return BindResult("already_bound", existing)
raise
except Exception:
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币"
# 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证建关系原子。
db.rollback()
raise
db.refresh(rel)
+5
View File
@@ -57,6 +57,11 @@ class EcpmReportIn(BaseModel):
)
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
feed_scene: str | None = Field(
None,
max_length=16,
description="点位场景:comparison(比价) / coupon(领券) / welfare(福利);激励视频为空",
)
class EcpmReportOut(BaseModel):
+2
View File
@@ -70,6 +70,7 @@ class ComparisonRecordIn(BaseModel):
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
# ===== 读取出参 =====
@@ -102,6 +103,7 @@ class ComparisonRecordOut(BaseModel):
items: list = []
comparison_results: list = []
skipped_dish_names: list = []
total_ms: int | None = None
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
ordered: bool = False
+31 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
class FeedbackOut(BaseModel):
@@ -12,3 +12,33 @@ class FeedbackOut(BaseModel):
id: int
status: str
created_at: datetime
class FeedbackConfigOut(BaseModel):
enabled: bool = True
image_url: str | None = None
title: str = "长按图片保存二维码"
group_name: str = "傻瓜比价官方群"
subtitle: str = "一起唠嗑共创、解锁新玩法"
class FeedbackRecordOut(BaseModel):
id: int
content: str
images: list[str] = Field(default_factory=list)
status: str
reject_reason: str | None = None
reward_coins: int | None = None
created_at: datetime
class FeedbackRecordCountsOut(BaseModel):
all: int = 0
pending: int = 0
adopted: int = 0
rejected: int = 0
class FeedbackRecordsOut(BaseModel):
records: list[FeedbackRecordOut]
counts: FeedbackRecordCountsOut
+3 -3
View File
@@ -10,7 +10,7 @@ class InviteInfoOut(BaseModel):
invite_code: str # 我的邀请码
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
invited_count: int # 已成功邀请人数
coins_earned: int # 累计从邀请获得的金币
coins_earned: int # 邀请金币已下线,保留字段兼容客户端(恒为 0)
class LandingTrackIn(BaseModel):
@@ -49,7 +49,7 @@ class BindInviteIn(BaseModel):
class BindInviteOut(BaseModel):
status: str # success / already_bound / invalid_code / self_invite / not_eligible / fp_not_found
coins_awarded: int # 本次给当前用户(被邀请人)发的金币
coins_awarded: int # 邀请金币已下线,保留字段兼容客户端(恒为 0)
message: str # 给前端直接展示的文案
@@ -62,7 +62,7 @@ class InviteeItem(BaseModel):
"""
display_name: str # 已兜底好的显示名(真名/脱敏号)
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
coins: int # 这次邀请给我(邀请人)发的金币
coins: int # 邀请金币已下线,保留字段兼容客户端(恒为 0)
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
@@ -37,7 +37,7 @@ B 安装并首启 App
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
后端 repositories/invite.py bind()
└─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交)
└─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水)
```
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()``POST /bind { channel="manual" }` → 同一个 `bind()`
@@ -52,20 +52,20 @@ B 安装并首启 App
|---|---|
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
| share_url 构造 | `invite.py``my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code``INVITE_LANDING_URL``app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 |
| 数据模型 | `app/models/invite.py``InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py``User.invite_code` 列。 |
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user``invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 |
| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 |
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。
2. **自邀屏蔽**:`inviter == invitee``self_invite`
3. **新人闸**:`_is_new_user`(B 的 `created_at``INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`
3. **新人闸**:`_is_new_user`(B 的 `created_at``INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
发金币复用 `repositories/wallet.py``grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子
邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0
### 3.2 前端(shaguabijia-app-android)
@@ -120,7 +120,7 @@ B 安装并首启 App
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。
- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。
- **A ≠ B**:自邀被屏蔽。
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
+10 -1
View File
@@ -15,4 +15,13 @@ fi
mkdir -p data # sqlite 文件所在目录
alembic upgrade head # 确保表已建(幂等,已是最新则 no-op)
exec uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
# 默认不开 --reload:Windows 下 --reload 会同时跑 reloader 主进程 + worker 两个进程,
# 二者都持有 logs/app-server.log 的 RotatingFileHandler,轮转(rename)时文件被占用 →
# WinError 32 刷屏(Linux 的 rename 宽松不会炸,故仅 Windows 受影响);且 watchfiles 监控
# logs 目录会自我放大。需要热重载(建议仅 Linux/Mac)用:RELOAD=1 ./run.sh
if [ "${RELOAD:-0}" = "1" ]; then
# 只监控 app 目录,避免再监控 logs/ 触发上述放大
exec uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload --reload-dir app
else
exec uvicorn app.main:app --host 0.0.0.0 --port 8770
fi
+352 -5
View File
@@ -1,14 +1,20 @@
"""Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。"""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.db.session import SessionLocal
from app.models.ad_ecpm import AdEcpmRecord
from app.models.comparison import ComparisonRecord
from app.models.cps_order import CpsOrder
from app.models.feedback import Feedback
from app.models.wallet import CashTransaction, WithdrawOrder
from app.models.wallet import CashTransaction, CoinTransaction, WithdrawOrder
from app.repositories import user as user_repo
from app.repositories import wallet as wallet_repo
@@ -49,7 +55,7 @@ def _seed_user_with_data(phone: str) -> int:
db.add(WithdrawOrder(
user_id=uid, out_bill_no=f"test{uid}billno0001", amount_cents=100, status="success"
))
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="new"))
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="pending"))
db.commit()
return uid
finally:
@@ -64,7 +70,347 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
assert data["users"]["total"] >= 1
assert data["coins"]["granted_total"] >= 5000
assert "success_rate" in data["comparison"]
assert data["cps"]["available"] is False
assert data["period"]["trend"]
assert {"date", "active_users", "new_users", "comparisons"} <= set(
data["period"]["trend"][0]
)
assert data["period"]["comparison"]["average_duration_ms"] is None
assert data["cps"]["available"] is True
def test_dashboard_average_duration_from_total_ms(
admin_client: TestClient, admin_token: str
) -> None:
day = "2020-02-23"
created_at = datetime(2020, 2, 23, 12, 0)
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(
db, phone="13800000008", register_channel="sms"
).id
db.add_all([
ComparisonRecord(
user_id=uid,
trace_id=f"duration-a-{uuid4().hex}",
business_type="food",
status="success",
items=[],
comparison_results=[],
skipped_dish_names=[],
total_ms=10000,
created_at=created_at,
),
ComparisonRecord(
user_id=uid,
trace_id=f"duration-b-{uuid4().hex}",
business_type="food",
status="failed",
items=[],
comparison_results=[],
skipped_dish_names=[],
total_ms=30000,
created_at=created_at,
),
])
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": day, "date_to": day},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
assert r.json()["period"]["comparison"]["average_duration_ms"] == 20000
def test_dashboard_meituan_cps_from_cps_order(
admin_client: TestClient, admin_token: str
) -> None:
db = SessionLocal()
try:
db.add_all([
CpsOrder(
order_id=f"mt-cps-hit-{uuid4().hex[:16]}",
pay_price_cents=3200,
commission_cents=160,
commission_rate="500",
mt_status="3",
pay_time=datetime(2020, 2, 24, 3, 0, tzinfo=timezone.utc),
raw={},
),
CpsOrder(
order_id=f"mt-cps-miss-{uuid4().hex[:16]}",
pay_price_cents=2600,
commission_cents=3,
commission_rate="10",
mt_status="6",
pay_time=datetime(2020, 2, 24, 5, 0, tzinfo=timezone.utc),
raw={},
),
CpsOrder(
order_id=f"mt-cps-cancel-{uuid4().hex[:16]}",
pay_price_cents=3000,
commission_cents=150,
commission_rate="500",
mt_status="4",
pay_time=datetime(2020, 2, 24, 6, 0, tzinfo=timezone.utc),
raw={},
),
])
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": "2020-02-24", "date_to": "2020-02-24"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
cps = r.json()["cps"]
assert cps["available"] is True
assert cps["meituan_order_count"] >= 2
assert cps["meituan_commission_cents"] >= 163
assert cps["meituan_hit_count"] >= 1
assert cps["meituan_miss_count"] >= 1
assert cps["meituan_hit_rate"] == 0.5
def test_admin_cps_reconcile_writes_meituan_orders(
admin_client: TestClient, admin_token: str, monkeypatch: pytest.MonkeyPatch
) -> None:
order_id = f"mt-api-{uuid4().hex[:16]}"
def fake_query_order(**kwargs):
assert kwargs["start_time"] > 0
assert kwargs["end_time"] > kwargs["start_time"]
assert kwargs["query_time_type"] == 1
return {
"code": 0,
"message": "成功",
"data": {
"scrollId": "next-scroll",
"dataList": [
{
"businessLine": 1,
"orderId": order_id,
"payTime": 1582502400,
"payPrice": "100.00",
"updateTime": 1582503000,
"commissionRate": "300",
"profit": "3.00",
"refundProfit": "null",
"status": "6",
"tradeType": 1,
"sid": "testsid",
"actId": 123,
"productName": "测试美团订单",
}
],
},
}
monkeypatch.setattr(
"app.admin.repositories.cps_orders.meituan.query_order",
fake_query_order,
)
r = admin_client.post(
"/admin/api/cps/orders/reconcile",
params={"date_from": "2020-02-24", "date_to": "2020-02-24"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
assert r.json()["inserted"] == 1
db = SessionLocal()
try:
order = db.query(CpsOrder).filter(CpsOrder.order_id == order_id).one()
assert order.commission_cents == 300
assert order.commission_rate == "300"
assert order.mt_status == "6"
assert order.product_name == "测试美团订单"
finally:
db.close()
def test_admin_comparison_records_return_total_ms(
admin_client: TestClient, admin_token: str
) -> None:
db = SessionLocal()
try:
user = user_repo.upsert_user_for_login(
db, phone="13800000009", register_channel="sms"
)
rec = ComparisonRecord(
user_id=user.id,
trace_id=f"admin-duration-{uuid4().hex}",
business_type="food",
status="success",
items=[],
comparison_results=[],
skipped_dish_names=[],
total_ms=45678,
created_at=datetime(2020, 2, 25, 12, 0),
)
db.add(rec)
db.commit()
rec_id = rec.id
finally:
db.close()
r = admin_client.get(
"/admin/api/comparison-records",
params={"phone": "13800000009"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["total"] >= 1
row = next(item for item in data["items"] if item["id"] == rec_id)
assert row["total_ms"] == 45678
assert row["phone"] == "13800000009"
r = admin_client.get(
f"/admin/api/comparison-records/{rec_id}",
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
assert r.json()["total_ms"] == 45678
def test_ad_revenue_report_by_ad_type(admin_client: TestClient, admin_token: str) -> None:
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(
db, phone="13800000006", register_channel="sms"
).id
db.add(AdEcpmRecord(
user_id=uid,
ad_type="reward_video",
ad_session_id=f"test-reward-video-{uuid4().hex}",
adn="pangle",
slot_id="test-slot",
ecpm_raw="1200",
report_date="2026-06-25",
created_at=datetime(2026, 6, 25, 8, 0, tzinfo=timezone.utc),
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/ad-revenue-report",
params={"date_from": "2026-06-25", "date_to": "2026-06-25"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["total_impressions"] >= 1
assert data["by_ad_type"]["reward_video"]["impressions"] >= 1
assert data["by_ad_type"]["reward_video"]["revenue_yuan"] > 0
def test_ad_revenue_report_keeps_feed_scene(admin_client: TestClient, admin_token: str) -> None:
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(
db, phone="13800000016", register_channel="sms"
).id
db.add_all([
AdEcpmRecord(
user_id=uid,
ad_type="draw",
feed_scene="coupon",
ad_session_id=f"test-draw-coupon-{uuid4().hex}",
adn="pangle",
slot_id="coupon-slot",
ecpm_raw="1200",
report_date="2026-06-24",
created_at=datetime(2026, 6, 24, 8, 0, tzinfo=timezone.utc),
),
AdEcpmRecord(
user_id=uid,
ad_type="draw",
feed_scene="comparison",
ad_session_id=f"test-draw-comparison-{uuid4().hex}",
adn="pangle",
slot_id="comparison-slot",
ecpm_raw="800",
report_date="2026-06-24",
created_at=datetime(2026, 6, 24, 9, 0, tzinfo=timezone.utc),
),
])
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/ad-revenue-report",
params={"date_from": "2026-06-24", "date_to": "2026-06-24"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
data = r.json()
scene_rows = {
item["feed_scene"]: item
for item in data["items"]
if item["user_id"] == uid and item["ad_type"] == "draw"
}
assert scene_rows["coupon"]["impressions"] == 1
assert scene_rows["coupon"]["revenue_yuan"] > 0
assert scene_rows["comparison"]["impressions"] == 1
assert scene_rows["comparison"]["revenue_yuan"] > 0
def test_dashboard_coin_category_mapping_without_feed_scene(
admin_client: TestClient, admin_token: str
) -> None:
day = "2020-02-20"
created_at = datetime(2020, 2, 20, 12, 0)
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(
db, phone="13800000007", register_channel="sms"
).id
rows = [
("reward_video", 100),
("coupon_reward", 80),
("compare_reward", 60),
("signin", 30),
("price_report_reward", 40),
("feed_ad_reward", 70),
("admin_grant", 1000),
("invite_inviter", 200),
]
balance = 0
for biz_type, amount in rows:
balance += amount
db.add(CoinTransaction(
user_id=uid,
amount=amount,
balance_after=balance,
biz_type=biz_type,
ref_id=f"coin-map-{biz_type}",
created_at=created_at,
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": day, "date_to": day},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
coins = r.json()["period"]["coins"]
assert coins["coupon_reward_coin_total"] >= 180
assert coins["comparison_reward_coin_total"] >= 60
assert coins["regular_task_coin_total"] >= 70
assert coins["regular_task_coin_total"] < coins["granted_total"]
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
@@ -107,9 +453,9 @@ def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -
def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
_seed_user_with_data("13800000005")
r = admin_client.get("/admin/api/feedbacks", params={"status": "new"}, headers=_auth(admin_token))
r = admin_client.get("/admin/api/feedbacks", params={"status": "pending"}, headers=_auth(admin_token))
assert r.status_code == 200
assert all(f["status"] == "new" for f in r.json()["items"])
assert all(f["status"] == "pending" for f in r.json()["items"])
def test_read_apis_require_auth(admin_client: TestClient) -> None:
@@ -120,5 +466,6 @@ def test_read_apis_require_auth(admin_client: TestClient) -> None:
"/admin/api/wallet/coin-transactions",
"/admin/api/withdraws",
"/admin/api/feedbacks",
"/admin/api/ad-revenue-report",
]:
assert admin_client.get(path).status_code == 401, path
+55 -6
View File
@@ -74,7 +74,7 @@ def _seed_feedback(phone: str) -> int:
uid = _seed_user(phone)
db = SessionLocal()
try:
fb = Feedback(user_id=uid, content="测试", contact="wx", status="new")
fb = Feedback(user_id=uid, content="测试", contact="wx", status="pending")
db.add(fb)
db.commit()
return fb.id
@@ -184,18 +184,67 @@ def test_super_admin_can_grant_coins(admin_client: TestClient, super_token: str)
assert r.status_code == 200
# ===== 反馈处理 =====
# ===== 反馈审核 =====
def test_handle_feedback(admin_client: TestClient, operator_token: str) -> None:
def test_approve_feedback_grants_coins_and_audit(admin_client: TestClient, operator_token: str) -> None:
fid = _seed_feedback("13900000006")
r = admin_client.post(f"/admin/api/feedbacks/{fid}/handle", headers=_auth(operator_token))
assert r.status_code == 200
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 600, "note": "建议已采纳"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
assert db.get(Feedback, fid).status == "handled"
fb = db.get(Feedback, fid)
assert fb.status == "adopted"
assert fb.reward_coins == 600
assert fb.review_note == "建议已采纳"
assert fb.reviewed_by_admin_id is not None
assert db.get(CoinAccount, fb.user_id).coin_balance == 600
txns = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == fb.user_id,
CoinTransaction.biz_type == "feedback_reward",
CoinTransaction.ref_id == str(fid),
)
).scalars().all()
assert len(txns) == 1 and txns[0].amount == 600
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "feedback.approve",
AdminAuditLog.target_id == str(fid),
)
).scalars().all()
assert len(logs) == 1 and logs[0].detail["reward_coins"] == 600
finally:
db.close()
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 600},
headers=_auth(operator_token),
)
assert r.status_code == 400
def test_reject_feedback_writes_reason(admin_client: TestClient, operator_token: str) -> None:
fid = _seed_feedback("13900000016")
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/reject",
json={"reason": "暂未提供足够复现信息", "note": "可引导补充截图"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
fb = db.get(Feedback, fid)
assert fb.status == "rejected"
assert fb.reject_reason == "暂未提供足够复现信息"
assert fb.review_note == "可引导补充截图"
assert db.get(CoinAccount, fb.user_id) is None
finally:
db.close()
# ===== admin 账号管理(super_admin) =====
+3
View File
@@ -44,6 +44,7 @@ def _food_payload(trace_id: str) -> dict:
"skipped_dish_names": ["黑牛肉卷"],
"total_dish_count": 3,
"information": "在美团找到同店,到手价 ¥123.50",
"total_ms": 12345,
}
@@ -71,6 +72,8 @@ def test_report_and_derive(client) -> None:
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
assert d["store_name"] == "海底捞(朝阳店)"
assert d["total_dish_count"] == 3
assert d["total_ms"] == 12345
assert d["raw_payload"]["total_ms"] == 12345
assert d["skipped_dish_count"] == 1
assert d["skipped_dish_names"] == ["黑牛肉卷"]
assert len(d["comparison_results"]) == 3
+77
View File
@@ -0,0 +1,77 @@
"""用户反馈接口测试。"""
from __future__ import annotations
from app.db.session import SessionLocal
from app.models.feedback import Feedback
from app.repositories import user as user_repo
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def test_feedback_records_empty_returns_200(client) -> None:
token = _login(client, "13622000001")
r = client.get("/api/v1/feedback/records", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
assert r.json() == {
"records": [],
"counts": {"all": 0, "pending": 0, "adopted": 0, "rejected": 0},
}
def test_feedback_config_returns_default(client) -> None:
token = _login(client, "13622000002")
r = client.get("/api/v1/feedback/config", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
body = r.json()
assert body["enabled"] is True
assert body["group_name"] == "傻瓜比价官方群"
def test_feedback_records_maps_existing_statuses(client) -> None:
phone = "13622000003"
token = _login(client, phone)
db = SessionLocal()
try:
user = user_repo.get_user_by_phone(db, phone)
assert user is not None
db.add(Feedback(user_id=user.id, content="待处理反馈", contact="", status="pending"))
db.add(
Feedback(
user_id=user.id,
content="已采纳反馈",
contact="",
status="adopted",
reward_coins=800,
)
)
db.add(
Feedback(
user_id=user.id,
content="未采纳反馈",
contact="",
status="rejected",
reject_reason="暂未提供可复现信息",
)
)
db.commit()
finally:
db.close()
r = client.get("/api/v1/feedback/records", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
body = r.json()
assert body["counts"] == {"all": 3, "pending": 1, "adopted": 1, "rejected": 1}
by_status = {item["status"]: item for item in body["records"]}
assert by_status["adopted"]["reward_coins"] == 800
assert by_status["rejected"]["reject_reason"] == "暂未提供可复现信息"
+21 -23
View File
@@ -1,4 +1,4 @@
"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽、指纹兜底归因。
"""好友邀请测试:邀请码、绑定、幂等、自邀/无效码屏蔽、指纹兜底归因。
sms mock 登录拿 token( test_welfare),再跑邀请闭环
"""
@@ -10,8 +10,6 @@ from sqlalchemy import select
from app.core.rewards import (
INVITE_FP_WINDOW_DAYS,
INVITE_INVITEE_COINS,
INVITE_INVITER_COINS,
INVITE_NEW_USER_WINDOW_HOURS,
)
from app.db.session import SessionLocal
@@ -56,8 +54,8 @@ def test_invite_me_returns_stable_code(client) -> None:
assert _my_code(client, token) == body["invite_code"]
def test_bind_flow_both_get_coins(client) -> None:
"""B 用 A 的码绑定 → 双方各得 1 万金币;A 战绩 +1"""
def test_bind_flow_records_relation_without_invite_coins(client) -> None:
"""B 用 A 的码绑定 → 邀请关系生效;邀请金币已下线"""
a = _login(client, "13800002002")
b = _login(client, "13800002003")
a_code = _my_code(client, a)
@@ -69,20 +67,20 @@ def test_bind_flow_both_get_coins(client) -> None:
assert r.status_code == 200, r.text
res = r.json()
assert res["status"] == "success"
assert res["coins_awarded"] == INVITE_INVITEE_COINS
assert res["coins_awarded"] == 0
# 双方金币到账
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
assert _coin_balance(client, a) == INVITE_INVITER_COINS
# 邀请金币已下线,双方余额不因邀请变化
assert _coin_balance(client, b) == 0
assert _coin_balance(client, a) == 0
# A 的战绩:已邀 1 人,累计获得 = 邀请人那份
# A 的战绩:已邀 1 人,邀请金币保持 0
r = client.get("/api/v1/invite/me", headers=_auth(a))
assert r.json()["invited_count"] == 1
assert r.json()["coins_earned"] == INVITE_INVITER_COINS
assert r.json()["coins_earned"] == 0
def test_bind_idempotent_no_double_reward(client) -> None:
"""同一被邀请人二次绑定 → already_bound,不重复发奖"""
def test_bind_idempotent_no_duplicate_relation(client) -> None:
"""同一被邀请人二次绑定 → already_bound,不重复绑定"""
a = _login(client, "13800002004")
b = _login(client, "13800002005")
a_code = _my_code(client, a)
@@ -99,14 +97,14 @@ def test_bind_idempotent_no_double_reward(client) -> None:
assert r2.json()["status"] == "already_bound"
assert r2.json()["coins_awarded"] == 0
# 余额没变(没二次发奖),C 也没拿到邀请奖励
# 余额没变,C 也没有邀请金币
assert _coin_balance(client, b) == bal_b
assert _coin_balance(client, a) == bal_a
assert _coin_balance(client, c) == 0
def test_self_invite_blocked(client) -> None:
"""填自己的邀请码 → self_invite,不发奖"""
"""填自己的邀请码 → self_invite,不产生金币"""
a = _login(client, "13800002007")
a_code = _my_code(client, a)
r = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(a))
@@ -116,7 +114,7 @@ def test_self_invite_blocked(client) -> None:
def test_invalid_code(client) -> None:
"""无效邀请码 → invalid_code,不发奖"""
"""无效邀请码 → invalid_code,不产生金币"""
b = _login(client, "13800002008")
r = client.post("/api/v1/invite/bind", json={"invite_code": "ZZZZZZ"}, headers=_auth(b))
assert r.status_code == 200, r.text
@@ -145,7 +143,7 @@ def test_invite_requires_auth(client) -> None:
def test_old_user_not_eligible(client) -> None:
"""新用户闸:被邀请人注册超过窗口 → not_eligible,双方都不发奖"""
"""新用户闸:被邀请人注册超过窗口 → not_eligible,双方都不产生金币"""
a = _login(client, "13800002030")
a_code = _my_code(client, a)
b = _login(client, "13800002031")
@@ -217,13 +215,13 @@ def test_bind_by_fingerprint_success(client) -> None:
)
assert r2.status_code == 200, r2.text
assert r2.json()["status"] == "success"
# 双方各发金币
assert _coin_balance(client, a) == INVITE_INVITER_COINS
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
# 邀请金币已下线,双方余额不因邀请变化
assert _coin_balance(client, a) == 0
assert _coin_balance(client, b) == 0
def test_bind_by_fingerprint_not_found(client) -> None:
"""没有匹配的指纹记录 → fp_not_found,不发奖"""
"""没有匹配的指纹记录 → fp_not_found,不产生金币"""
b = _login(client, "13800002043")
r = client.post(
"/api/v1/invite/bind",
@@ -300,7 +298,7 @@ def test_bind_no_code_no_fingerprint(client) -> None:
# =====================================================================
def test_invitees_basic(client) -> None:
"""A 邀 B、C → 列表返 2 条、total=2、没设资料的名字=脱敏手机号、头像 null、金币"""
"""A 邀 B、C → 列表返 2 条、total=2、没设资料的名字=脱敏手机号、头像 null、金币为 0"""
a = _login(client, "13800002050")
a_code = _my_code(client, a)
for phone in ("13800002051", "13800002052"):
@@ -317,7 +315,7 @@ def test_invitees_basic(client) -> None:
names = {it["display_name"] for it in body["items"]}
assert names == {"138****2051", "138****2052"}
assert all(it["avatar_url"] is None for it in body["items"])
assert all(it["coins"] == INVITE_INVITER_COINS for it in body["items"])
assert all(it["coins"] == 0 for it in body["items"])
def test_invitees_order_desc(client) -> None:
+2 -1
View File
@@ -52,7 +52,8 @@ def call_raw(path: str, body_obj: dict) -> dict:
}
url = f"{settings.MT_CPS_HOST}{path}"
t0 = time.time()
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
# trust_env=False: 美团是国内域名,强制直连绕开本机代理(代理会掐断 TLS 握手,报 SSL EOF)
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC, trust_env=False)
ms = int((time.time() - t0) * 1000)
try:
j = resp.json()