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