diff --git a/alembic/versions/ad_ecpm_feed_scene.py b/alembic/versions/ad_ecpm_feed_scene.py new file mode 100644 index 0000000..0287d17 --- /dev/null +++ b/alembic/versions/ad_ecpm_feed_scene.py @@ -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") diff --git a/alembic/versions/comparison_record_total_ms.py b/alembic/versions/comparison_record_total_ms.py new file mode 100644 index 0000000..23424d4 --- /dev/null +++ b/alembic/versions/comparison_record_total_ms.py @@ -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") diff --git a/alembic/versions/cps_order_dashboard.py b/alembic/versions/cps_order_dashboard.py new file mode 100644 index 0000000..08d90aa --- /dev/null +++ b/alembic/versions/cps_order_dashboard.py @@ -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") diff --git a/alembic/versions/feedback_review_fields.py b/alembic/versions/feedback_review_fields.py new file mode 100644 index 0000000..40fb6de --- /dev/null +++ b/alembic/versions/feedback_review_fields.py @@ -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") diff --git a/app/admin/main.py b/app/admin/main.py index b5948a9..685983e 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -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) diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py index c882b12..5ee02da 100644 --- a/app/admin/repositories/ad_audit.py +++ b/app/admin/repositories/ad_audit.py @@ -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] diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py new file mode 100644 index 0000000..fa4551a --- /dev/null +++ b/app/admin/repositories/ad_revenue.py @@ -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, + } diff --git a/app/admin/repositories/cps_orders.py b/app/admin/repositories/cps_orders.py new file mode 100644 index 0000000..48965fe --- /dev/null +++ b/app/admin/repositories/cps_orders.py @@ -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} diff --git a/app/admin/repositories/mutations.py b/app/admin/repositories/mutations.py index 1f7d230..145aeb6 100644 --- a/app/admin/repositories/mutations.py +++ b/app/admin/repositories/mutations.py @@ -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, diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index ce88603..d391550 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -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, *, diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 199ff31..507f5fc 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -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, + }, } diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py new file mode 100644 index 0000000..845ad95 --- /dev/null +++ b/app/admin/routers/ad_revenue.py @@ -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"]], + ) diff --git a/app/admin/routers/comparison.py b/app/admin/routers/comparison.py new file mode 100644 index 0000000..cb3486e --- /dev/null +++ b/app/admin/routers/comparison.py @@ -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) diff --git a/app/admin/routers/cps.py b/app/admin/routers/cps.py new file mode 100644 index 0000000..cbb556f --- /dev/null +++ b/app/admin/routers/cps.py @@ -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) diff --git a/app/admin/routers/dashboard.py b/app/admin/routers/dashboard.py index 138c8d8..d8f1201 100644 --- a/app/admin/routers/dashboard.py +++ b/app/admin/routers/dashboard.py @@ -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) + ) diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 23f21de..ec4705f 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -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() diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py new file mode 100644 index 0000000..e62c65f --- /dev/null +++ b/app/admin/schemas/ad_revenue.py @@ -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] diff --git a/app/admin/schemas/common.py b/app/admin/schemas/common.py index 6987eeb..91455a3 100644 --- a/app/admin/schemas/common.py +++ b/app/admin/schemas/common.py @@ -13,6 +13,7 @@ class CursorPage(BaseModel, Generic[T]): items: list[T] next_cursor: int | None = None + total: int | None = None class OkResponse(BaseModel): diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py new file mode 100644 index 0000000..f89c7e4 --- /dev/null +++ b/app/admin/schemas/comparison.py @@ -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 diff --git a/app/admin/schemas/cps.py b/app/admin/schemas/cps.py new file mode 100644 index 0000000..d180daa --- /dev/null +++ b/app/admin/schemas/cps.py @@ -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 diff --git a/app/admin/schemas/dashboard.py b/app/admin/schemas/dashboard.py index 10d9dfa..0ec1afb 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -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 diff --git a/app/admin/schemas/feedback.py b/app/admin/schemas/feedback.py index c32889a..bec9107 100644 --- a/app/admin/schemas/feedback.py +++ b/app/admin/schemas/feedback.py @@ -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="运营内部审核备注") diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 0c7e351..b1b3cc5 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -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) diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py index cc30d86..3061b3e 100644 --- a/app/api/v1/feedback.py +++ b/app/api/v1/feedback.py @@ -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="联系方式过长") diff --git a/app/api/v1/invite.py b/app/api/v1/invite.py index c5de448..cb2c81b 100644 --- a/app/api/v1/invite.py +++ b/app/api/v1/invite.py @@ -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: diff --git a/app/core/logging.py b/app/core/logging.py index 56b856b..d4cf415 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -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 diff --git a/app/core/rewards.py b/app/core/rewards.py index 84de0c7..8189bb0 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -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 diff --git a/app/integrations/meituan.py b/app/integrations/meituan.py index 6d1b10a..3898822 100644 --- a/app/integrations/meituan.py +++ b/app/integrations/meituan.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index a9d0488..897857f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index 0f46b51..053dd58 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -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) diff --git a/app/models/comparison.py b/app/models/comparison.py index 7a42a4f..bcc5e7f 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -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 ) diff --git a/app/models/cps_order.py b/app/models/cps_order.py new file mode 100644 index 0000000..623504d --- /dev/null +++ b/app/models/cps_order.py @@ -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 + ) diff --git a/app/models/feedback.py b/app/models/feedback.py index 004d9e2..3e8193f 100644 --- a/app/models/feedback.py +++ b/app/models/feedback.py @@ -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 diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index 9841ae9..9c888d2 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -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(), ) diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 640ffa0..f9590e5 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -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], diff --git a/app/repositories/feedback.py b/app/repositories/feedback.py index fc20b1f..6c5f8a5 100644 --- a/app/repositories/feedback.py +++ b/app/repositories/feedback.py @@ -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()) diff --git a/app/repositories/invite.py b/app/repositories/invite.py index ca36e31..65f82e3 100644 --- a/app/repositories/invite.py +++ b/app/repositories/invite.py @@ -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) diff --git a/app/schemas/ad.py b/app/schemas/ad.py index a1cb7e0..53c2160 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -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): diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 2a6ba5f..8693d14 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -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 diff --git a/app/schemas/feedback.py b/app/schemas/feedback.py index 729025c..782fd2f 100644 --- a/app/schemas/feedback.py +++ b/app/schemas/feedback.py @@ -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 diff --git a/app/schemas/invite.py b/app/schemas/invite.py index 0564595..5a215f2 100644 --- a/app/schemas/invite.py +++ b/app/schemas/invite.py @@ -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天前") diff --git a/docs/邀请功能-实现原理与本地测试.md b/docs/邀请功能-实现原理与本地测试.md index 1a8b147..e2f8821 100644 --- a/docs/邀请功能-实现原理与本地测试.md +++ b/docs/邀请功能-实现原理与本地测试.md @@ -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。 diff --git a/run.sh b/run.sh index bc00fe8..13141c2 100755 --- a/run.sh +++ b/run.sh @@ -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 diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 4111d50..ed569c6 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -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 diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index 67bc719..b9b25a9 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -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) ===== diff --git a/tests/test_compare_record.py b/tests/test_compare_record.py index 85696f0..38548e1 100644 --- a/tests/test_compare_record.py +++ b/tests/test_compare_record.py @@ -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 diff --git a/tests/test_feedback.py b/tests/test_feedback.py new file mode 100644 index 0000000..8ec524f --- /dev/null +++ b/tests/test_feedback.py @@ -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"] == "暂未提供可复现信息" diff --git a/tests/test_invite.py b/tests/test_invite.py index 574adad..40ee482 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -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: diff --git a/tools/meituan_playground.py b/tools/meituan_playground.py index 0e9f165..0664d36 100644 --- a/tools/meituan_playground.py +++ b/tools/meituan_playground.py @@ -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()