From ee6c2e4710f4dcd106f980682e10cc2d9b8c2556 Mon Sep 17 00:00:00 2001 From: xianze Date: Sun, 31 May 2026 21:30:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=B0=E8=B4=A6):=20=E6=AF=94=E4=BB=B7?= =?UTF-8?q?=E7=9C=81=E9=92=B1=E8=AE=B0=E8=B4=A6=E5=90=8E=E7=AB=AF=20+=20?= =?UTF-8?q?=E6=AD=A4=E5=89=8D=E6=9C=AA=E6=8F=90=E4=BA=A4=E7=9A=84=E5=BD=92?= =?UTF-8?q?=E5=9B=A0=E8=AE=A2=E5=8D=95=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次(M1 记账): savings_record 升级为记账唯一真相表(新增 源平台原价/比价价/支付渠道/平台包名/源平台名/源链接/幂等键/device_id, 加 user_id+client_event_id 唯一约束); /api/v1/order/report 改为写 savings_record(source=compare), 省额=源平台原价 减 实付, 按 client_event_id 幂等; 统计与明细改为 有真实记录用真实、否则 demo 兜底; 修复 dishes 列 JSONB 改 with_variant 兼容 SQLite 测试库; 新增 tests/test_order_savings.py, 全量 pytest 54 passed 一并纳入(此前工作区已有、未提交, 非本次改动): order_record 归因订单 模型/repo/api/迁移, 及 main.py、models/__init__ 注册等改动 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...14b4_merge_feedback_savings_jsonb_heads.py | 26 +++++ alembic/versions/order_record_table.py | 42 +++++++ alembic/versions/savings_report_fields.py | 49 ++++++++ app/api/v1/order.py | 53 +++++++++ app/main.py | 2 + app/models/__init__.py | 1 + app/models/order.py | 42 +++++++ app/models/savings.py | 30 ++++- app/repositories/order.py | 59 ++++++++++ app/repositories/savings.py | 94 ++++++++++++++-- app/schemas/order.py | 51 +++++++++ app/schemas/welfare.py | 7 +- tests/test_order_savings.py | 105 ++++++++++++++++++ 13 files changed, 547 insertions(+), 14 deletions(-) create mode 100644 .alembic-archive/a7afc22c14b4_merge_feedback_savings_jsonb_heads.py create mode 100644 alembic/versions/order_record_table.py create mode 100644 alembic/versions/savings_report_fields.py create mode 100644 app/api/v1/order.py create mode 100644 app/models/order.py create mode 100644 app/repositories/order.py create mode 100644 app/schemas/order.py create mode 100644 tests/test_order_savings.py diff --git a/.alembic-archive/a7afc22c14b4_merge_feedback_savings_jsonb_heads.py b/.alembic-archive/a7afc22c14b4_merge_feedback_savings_jsonb_heads.py new file mode 100644 index 0000000..47d35f9 --- /dev/null +++ b/.alembic-archive/a7afc22c14b4_merge_feedback_savings_jsonb_heads.py @@ -0,0 +1,26 @@ +"""merge feedback + savings jsonb heads + +Revision ID: a7afc22c14b4 +Revises: ef96beb47b1e, d1e2f3a4b5c6 +Create Date: 2026-05-30 10:40:10.703958 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a7afc22c14b4' +down_revision: Union[str, Sequence[str], None] = ('ef96beb47b1e', 'd1e2f3a4b5c6') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/order_record_table.py b/alembic/versions/order_record_table.py new file mode 100644 index 0000000..e6fae4e --- /dev/null +++ b/alembic/versions/order_record_table.py @@ -0,0 +1,42 @@ +"""order record table (归因订单) + +Revision ID: order_record_table +Revises: f01db5d77dac +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "order_record_table" +down_revision = "f01db5d77dac" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "order_record", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False, index=True), + sa.Column("client_event_id", sa.String(length=64), nullable=False), + sa.Column("platform", sa.String(length=32), nullable=False), + sa.Column("platform_package", sa.String(length=128), nullable=True), + sa.Column("pay_channel", sa.String(length=16), nullable=False), + sa.Column("compared_price_cents", sa.Integer(), nullable=False), + sa.Column("paid_amount_cents", sa.Integer(), nullable=False), + sa.Column("device_id", sa.String(length=128), nullable=True), + sa.Column("source", sa.String(length=32), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("user_id", "client_event_id", name="uq_order_user_event"), + ) + + +def downgrade() -> None: + op.drop_table("order_record") diff --git a/alembic/versions/savings_report_fields.py b/alembic/versions/savings_report_fields.py new file mode 100644 index 0000000..26d5967 --- /dev/null +++ b/alembic/versions/savings_report_fields.py @@ -0,0 +1,49 @@ +"""savings_record:真实比价上报字段 + (user_id, client_event_id) 幂等唯一约束 + +把 savings_record 升级为「记账唯一真相表」:真实上报(source='compare')写入 +原价/比价价/支付渠道/平台包名/源平台名/源链接/幂等键/device_id; +demo seeder 行(source='demo')这些列均为 NULL。 + +Revision ID: savings_report_fields +Revises: order_record_table +Create Date: 2026-05-31 19:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'savings_report_fields' +down_revision: Union[str, Sequence[str], None] = 'order_record_table' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('savings_record', schema=None) as batch_op: + batch_op.add_column(sa.Column('original_price_cents', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('compared_price_cents', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('pay_channel', sa.String(length=16), nullable=True)) + batch_op.add_column(sa.Column('platform_package', sa.String(length=128), nullable=True)) + batch_op.add_column(sa.Column('source_platform_name', sa.String(length=32), nullable=True)) + batch_op.add_column(sa.Column('source_deeplink', sa.String(length=512), nullable=True)) + batch_op.add_column(sa.Column('client_event_id', sa.String(length=64), nullable=True)) + batch_op.add_column(sa.Column('device_id', sa.String(length=128), nullable=True)) + # (user_id, client_event_id) 幂等;demo 行 client_event_id 为 NULL 不冲突 + batch_op.create_unique_constraint('uq_savings_user_event', ['user_id', 'client_event_id']) + + +def downgrade() -> None: + with op.batch_alter_table('savings_record', schema=None) as batch_op: + batch_op.drop_constraint('uq_savings_user_event', type_='unique') + batch_op.drop_column('device_id') + batch_op.drop_column('client_event_id') + batch_op.drop_column('source_deeplink') + batch_op.drop_column('source_platform_name') + batch_op.drop_column('platform_package') + batch_op.drop_column('pay_channel') + batch_op.drop_column('compared_price_cents') + batch_op.drop_column('original_price_cents') diff --git a/app/api/v1/order.py b/app/api/v1/order.py new file mode 100644 index 0000000..95ccb07 --- /dev/null +++ b/app/api/v1/order.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter + +from app.api.deps import CurrentUser, DbSession +from app.repositories import order as crud_order +from app.repositories import savings as crud_savings +from app.schemas.order import ( + OrderListItem, + OrderListOut, + OrderReportOut, + OrderReportRequest, +) + +router = APIRouter(prefix="/api/v1/order", tags=["order"]) + + +@router.post( + "/report", + response_model=OrderReportOut, + summary="上报归因订单(比价后5分钟内点链接 + 支付金额与比价价相差≤1元)", +) +def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut: + # 记账唯一真相表是 savings_record(source='compare');order_record 已降级,不再写入。 + rec, duplicated = crud_savings.create_from_report(db, user.id, req) + return OrderReportOut( + id=rec.id, + platform=rec.platform or req.platform, + pay_channel=rec.pay_channel or req.pay_channel, + compared_price_cents=rec.compared_price_cents or 0, + paid_amount_cents=rec.order_amount_cents, + duplicated=duplicated, + ) + + +@router.get( + "/list", + response_model=OrderListOut, + summary="当前用户的归因订单明细(按时间倒序)", +) +def list_orders(user: CurrentUser, db: DbSession, limit: int = 100) -> OrderListOut: + rows, total = crud_order.list_orders(db, user.id, limit=limit) + items = [ + OrderListItem( + id=r.id, + platform=r.platform, + pay_channel=r.pay_channel, + compared_price_cents=r.compared_price_cents, + paid_amount_cents=r.paid_amount_cents, + saved_cents=max(0, r.compared_price_cents - r.paid_amount_cents), + created_at=r.created_at, + ) + for r in rows + ] + return OrderListOut(items=items, total=total) diff --git a/app/main.py b/app/main.py index 14686bb..b3b0e6c 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ from app.api.v1.compare import router as compare_router from app.api.v1.coupon import router as coupon_router from app.api.v1.feedback import router as feedback_router from app.api.v1.meituan import router as meituan_router +from app.api.v1.order import router as order_router from app.api.v1.savings import router as savings_router from app.api.v1.signin import router as signin_router from app.api.v1.tasks import router as tasks_router @@ -80,6 +81,7 @@ app.include_router(signin_router) app.include_router(tasks_router) app.include_router(savings_router) app.include_router(ad_router) +app.include_router(order_router) # 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。 _media_root = Path(settings.MEDIA_ROOT) diff --git a/app/models/__init__.py b/app/models/__init__.py index 047a2a6..5dd5d5a 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,7 @@ """所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。""" from app.models.ad_reward import AdRewardRecord # noqa: F401 from app.models.feedback import Feedback # noqa: F401 +from app.models.order import OrderRecord # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401 from app.models.signin import SigninRecord # noqa: F401 from app.models.task import UserTask # noqa: F401 diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..88004fa --- /dev/null +++ b/app/models/order.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class OrderRecord(Base): + """归因订单记录。 + + 客户端判定「比价完 5 分钟内 + 用户点了结果链接 + 支付金额与比价价相差 ≤1 元」成立时, + 带 JWT 上报一条,视为用户经由我们下了一笔单。本表只做记录(不发金币,避免刷单风险)。 + + 幂等:(user_id, client_event_id) 唯一。client_event_id 由客户端为每次归因生成 UUID, + 重试/重复上报不会产生多条。 + """ + + __tablename__ = "order_record" + __table_args__ = ( + UniqueConstraint("user_id", "client_event_id", name="uq_order_user_event"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + client_event_id: Mapped[str] = mapped_column(String(64), nullable=False) + # 平台展示名(如 美团 / 淘宝闪购),来自比价结果 comparison_results.platform_name + platform: Mapped[str] = mapped_column(String(32), nullable=False) + platform_package: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 支付渠道:wechat / alipay(哪条支付通道检测到的支付成功) + pay_channel: Mapped[str] = mapped_column(String(16), nullable=False) + # 我们给出的(用户点击的那个平台的)比价价,单位分 + compared_price_cents: Mapped[int] = mapped_column(Integer, nullable=False) + # 实际支付金额,单位分 + paid_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False) + device_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + source: Mapped[str] = mapped_column(String(32), nullable=False, default="attribution") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/savings.py b/app/models/savings.py index 317a670..0a85c3f 100644 --- a/app/models/savings.py +++ b/app/models/savings.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, UniqueConstraint, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -17,6 +17,11 @@ from app.db.base import Base class SavingsRecord(Base): __tablename__ = "savings_record" + __table_args__ = ( + # 真实上报(source='compare')按 (user, client_event_id) 幂等; + # demo 行 client_event_id 为 NULL,不参与唯一性冲突(SQLite/PG 均允许多 NULL) + UniqueConstraint("user_id", "client_event_id", name="uq_savings_user_event"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( @@ -29,11 +34,30 @@ class SavingsRecord(Base): title: Mapped[str | None] = mapped_column(String(128), nullable=True) # 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」) shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True) - # 菜品名列表(JSONB),PG 上可建 GIN 索引,前 2 道直接展示,其余收进「还有 N 道菜」展开。 - dishes: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) + # 菜品名列表:PG 用 JSONB(可建 GIN 索引),SQLite 用 JSON(TEXT)。前 2 道直接展示,其余收进「还有 N 道菜」。 + dishes: Mapped[list[str]] = mapped_column( + JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list + ) # 来源:demo(演示) / compare(真实比价上报) source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare") + # ===== 真实比价上报(source='compare')新增字段;demo 行这些均为 NULL ===== + # 源平台原价(用户原本要付的钱,分);省额 saved_amount_cents = original − order_amount(实付) + original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 我们当时给出的比价价(分),审计/备用展示 + compared_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 支付渠道:wechat / alipay + pay_channel: Mapped[str | None] = mapped_column(String(16), nullable=True) + # 实际下单平台包名 + platform_package: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 源平台展示名(如「美团」),用于「原价 ¥36.8(美团)」展示 + source_platform_name: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 源平台重进链接(预留:后续「重新进店/重复下单」用,本期只存不展示) + source_deeplink: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 客户端幂等键(UUID);demo 行为 NULL + client_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + device_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False ) diff --git a/app/repositories/order.py b/app/repositories/order.py new file mode 100644 index 0000000..87c6823 --- /dev/null +++ b/app/repositories/order.py @@ -0,0 +1,59 @@ +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.order import OrderRecord +from app.schemas.order import OrderReportRequest + + +def create_order_report( + db: Session, user_id: int, req: OrderReportRequest +) -> tuple[OrderRecord, bool]: + """记录一条归因订单。返回 (记录, 是否为重复上报)。 + + 幂等:同 (user_id, client_event_id) 已存在则直接返回旧记录,duplicated=True,不新增。 + """ + existing = db.execute( + select(OrderRecord).where( + OrderRecord.user_id == user_id, + OrderRecord.client_event_id == req.client_event_id, + ) + ).scalar_one_or_none() + if existing is not None: + return existing, True + + rec = OrderRecord( + user_id=user_id, + client_event_id=req.client_event_id, + platform=req.platform, + platform_package=req.platform_package, + pay_channel=req.pay_channel, + compared_price_cents=req.compared_price_cents, + paid_amount_cents=req.paid_amount_cents, + device_id=req.device_id, + source="attribution", + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec, False + + +def list_orders( + db: Session, user_id: int, limit: int = 100, offset: int = 0 +) -> tuple[list[OrderRecord], int]: + """按 id 倒序返回某用户的归因订单 + 总数。""" + total = db.scalar( + select(func.count()).select_from(OrderRecord).where(OrderRecord.user_id == user_id) + ) or 0 + rows = ( + db.execute( + select(OrderRecord) + .where(OrderRecord.user_id == user_id) + .order_by(OrderRecord.id.desc()) + .limit(limit) + .offset(offset) + ) + .scalars() + .all() + ) + return list(rows), int(total) diff --git a/app/repositories/savings.py b/app/repositories/savings.py index ecc6870..b1901a8 100644 --- a/app/repositories/savings.py +++ b/app/repositories/savings.py @@ -17,6 +17,7 @@ from sqlalchemy.orm import Session from app.core.rewards import CN_TZ, cn_today from app.models.savings import SavingsRecord +from app.schemas.order import OrderReportRequest # 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单 _DEMO_STREAK_DAYS = 9 @@ -65,6 +66,26 @@ def _all_records(db: Session, user_id: int) -> list[SavingsRecord]: return list(db.execute(stmt).scalars().all()) +def _has_real(db: Session, user_id: int) -> bool: + """该用户是否已有真实比价上报(source='compare')记录。""" + return db.execute( + select(SavingsRecord.id) + .where(SavingsRecord.user_id == user_id, SavingsRecord.source == "compare") + .limit(1) + ).first() is not None + + +def _effective_records(db: Session, user_id: int) -> list[SavingsRecord]: + """统计口径:有真实(compare)记录就只用真实的;否则 seed 一批 demo 并用 demo 兜底。""" + if _has_real(db, user_id): + stmt = select(SavingsRecord).where( + SavingsRecord.user_id == user_id, SavingsRecord.source == "compare" + ) + return list(db.execute(stmt).scalars().all()) + ensure_seeded(db, user_id) + return _all_records(db, user_id) + + def ensure_seeded(db: Session, user_id: int) -> None: """该用户没有任何省钱记录时,幂等灌一批 demo 数据。""" exists = db.execute( @@ -108,8 +129,7 @@ def ensure_seeded(db: Session, user_id: int) -> None: def get_summary(db: Session, user_id: int) -> SavingsSummary: - ensure_seeded(db, user_id) - records = _all_records(db, user_id) + records = _effective_records(db, user_id) total = sum(r.saved_amount_cents for r in records) count = len(records) avg = total // count if count else 0 @@ -129,8 +149,7 @@ def _streak_days(dates: set) -> int: def get_battle(db: Session, user_id: int) -> SavingsBattle: - ensure_seeded(db, user_id) - records = _all_records(db, user_id) + records = _effective_records(db, user_id) today = cn_today() week_start = today - timedelta(days=today.weekday()) # 本周一 @@ -155,10 +174,20 @@ def _compute_beat_percent(db: Session, user_id: int) -> int: 全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。 """ rows = db.execute( - select(SavingsRecord.user_id, func.sum(SavingsRecord.saved_amount_cents)) - .group_by(SavingsRecord.user_id) + select( + SavingsRecord.user_id, + SavingsRecord.source, + func.sum(SavingsRecord.saved_amount_cents), + ).group_by(SavingsRecord.user_id, SavingsRecord.source) ).all() - totals = {uid: (total or 0) for uid, total in rows} + # 每用户口径与展示一致:有 compare 用 compare 之和,否则退回 demo 之和 + by_user: dict[int, dict[str, int]] = {} + for uid, source, total in rows: + by_user.setdefault(uid, {})[source] = total or 0 + totals = { + uid: (d["compare"] if "compare" in d else d.get("demo", 0)) + for uid, d in by_user.items() + } others = {uid: t for uid, t in totals.items() if uid != user_id} if not others: return 0 @@ -174,9 +203,12 @@ def list_records( limit: int = 20, cursor: int | None = None, ) -> tuple[list[SavingsRecord], int | None]: - """省钱明细分页(按 id 倒序,游标式)。""" - ensure_seeded(db, user_id) + """省钱明细分页(按 id 倒序,游标式)。有真实(compare)记录只列真实,否则 demo 兜底。""" stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id) + if _has_real(db, user_id): + stmt = stmt.where(SavingsRecord.source == "compare") + else: + ensure_seeded(db, user_id) if cursor is not None: stmt = stmt.where(SavingsRecord.id < cursor) stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit) @@ -184,3 +216,47 @@ def list_records( items = list(db.execute(stmt).scalars().all()) next_cursor = items[-1].id if len(items) == limit else None return items, next_cursor + + +def create_from_report( + db: Session, user_id: int, req: OrderReportRequest +) -> tuple[SavingsRecord, bool]: + """真实比价上报 → 写入一条 savings_record(source='compare')。返回 (记录, 是否重复上报)。 + + 省额 = 源平台原价 − 实付(下限 0);原价缺失则记 0。 + 幂等:同 (user_id, client_event_id) 已存在则直接返回旧记录,duplicated=True,不新增。 + """ + existing = db.execute( + select(SavingsRecord).where( + SavingsRecord.user_id == user_id, + SavingsRecord.client_event_id == req.client_event_id, + ) + ).scalar_one_or_none() + if existing is not None: + return existing, True + + paid = req.paid_amount_cents + original = req.original_price_cents + saved = max(0, original - paid) if original is not None else 0 + rec = SavingsRecord( + user_id=user_id, + order_amount_cents=paid, + saved_amount_cents=saved, + original_price_cents=original, + compared_price_cents=req.compared_price_cents, + platform=req.platform, + title=req.shop_name, # 明细卡标题用门店名 + shop_name=req.shop_name, + dishes=req.dishes or [], + pay_channel=req.pay_channel, + platform_package=req.platform_package, + source_platform_name=req.source_platform_name, + source_deeplink=req.source_deeplink, + client_event_id=req.client_event_id, + device_id=req.device_id, + source="compare", + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec, False diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..befd5e8 --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,51 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class OrderReportRequest(BaseModel): + """客户端归因成功后的上报体。金额一律用「分」(int) 传,避免浮点误差。""" + + client_event_id: str = Field(..., max_length=64, description="客户端幂等键(UUID)") + platform: str = Field(..., max_length=32, description="平台展示名,如 美团") + platform_package: str | None = Field(None, max_length=128, description="平台包名") + pay_channel: str = Field(..., max_length=16, description="支付渠道 wechat/alipay") + compared_price_cents: int = Field(..., ge=0, description="我们给出的比价价(分)") + paid_amount_cents: int = Field(..., ge=0, description="实际支付金额(分)") + device_id: str | None = Field(None, max_length=128) + # ===== 比价时携带的记账信息(客户端从意图识别阶段缓存而来;旧版客户端可能不传,故全部可空)===== + shop_name: str | None = Field(None, max_length=128, description="门店名,如 肯德基宅急送(天北路店)") + dishes: list[str] = Field(default_factory=list, description="菜品名列表") + original_price_cents: int | None = Field(None, ge=0, description="源平台原价(分),省额=原价−实付") + source_platform_name: str | None = Field(None, max_length=32, description="源平台展示名,如 美团") + source_deeplink: str | None = Field(None, max_length=512, description="源平台重进链接(预留,本期只存不展示)") + + +class OrderReportOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + platform: str + pay_channel: str + compared_price_cents: int + paid_amount_cents: int + duplicated: bool = False + + +class OrderListItem(BaseModel): + """订单明细一条(展示用)。saved_cents = 比价价 − 实付(下限 0)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + platform: str + pay_channel: str + compared_price_cents: int + paid_amount_cents: int + saved_cents: int + created_at: datetime + + +class OrderListOut(BaseModel): + items: list[OrderListItem] + total: int diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index b49cc92..c7630fc 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -200,12 +200,15 @@ class SavingsRecordOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - order_amount_cents: int - saved_amount_cents: int + order_amount_cents: int # 实付金额(分) + saved_amount_cents: int # 省下(分) + original_price_cents: int | None = None # 源平台原价(分);demo 行为空 platform: str | None = None title: str | None = None shop_name: str | None = None dishes: list[str] = [] + pay_channel: str | None = None # wechat/alipay;demo 行为空 + source_platform_name: str | None = None # 源平台名,如 美团 created_at: datetime diff --git a/tests/test_order_savings.py b/tests/test_order_savings.py new file mode 100644 index 0000000..d6b1226 --- /dev/null +++ b/tests/test_order_savings.py @@ -0,0 +1,105 @@ +"""记账测试:比价后下单上报 → 写 savings_record(source='compare')。 + +覆盖:上报落库 + 门店/菜品/原价随之入库、(user,client_event_id) 幂等、 +真实(compare)记录优先于 demo seeder、省额 = 源平台原价 − 实付。 +""" +from __future__ import annotations + + +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 _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _report_body(**over) -> dict: + body = { + "client_event_id": "evt-1", + "platform": "淘宝闪购", + "platform_package": "com.taobao.taobao", + "pay_channel": "alipay", + "compared_price_cents": 3300, + "paid_amount_cents": 3300, + "device_id": "device_test", + "shop_name": "肯德基宅急送(天北路店)", + "dishes": ["史迪奇玩具套餐 x1"], + "original_price_cents": 3680, + "source_platform_name": "美团", + "source_deeplink": "https://example.com/reorder", + } + body.update(over) + return body + + +def test_order_report_creates_real_savings(client) -> None: + """上报一笔 → savings 统计/明细只反映这笔真实数据,含门店/菜品/原价。""" + token = _login(client, "13800002001") + r = client.post("/api/v1/order/report", json=_report_body(), headers=_auth(token)) + assert r.status_code == 200, r.text + out = r.json() + assert out["duplicated"] is False + assert out["paid_amount_cents"] == 3300 + assert out["platform"] == "淘宝闪购" + + # summary:真实优先,只算这一笔(demo 不再混入) + s = client.get("/api/v1/savings/summary", headers=_auth(token)).json() + assert s["order_count"] == 1 + assert s["total_saved_cents"] == 380 # 3680 − 3300 + assert s["avg_saved_cents"] == 380 + + # records:门店/菜品/原价/支付渠道/源平台都在 + page = client.get("/api/v1/savings/records", headers=_auth(token)).json() + assert len(page["items"]) == 1 + it = page["items"][0] + assert it["shop_name"] == "肯德基宅急送(天北路店)" + assert it["dishes"] == ["史迪奇玩具套餐 x1"] + assert it["order_amount_cents"] == 3300 + assert it["saved_amount_cents"] == 380 + assert it["original_price_cents"] == 3680 + assert it["platform"] == "淘宝闪购" + assert it["pay_channel"] == "alipay" + assert it["source_platform_name"] == "美团" + + +def test_order_report_idempotent(client) -> None: + """同 (user, client_event_id) 重复上报:第二次 duplicated=True,不新增。""" + token = _login(client, "13800002002") + body = _report_body(client_event_id="evt-dup") + r1 = client.post("/api/v1/order/report", json=body, headers=_auth(token)) + assert r1.json()["duplicated"] is False + r2 = client.post("/api/v1/order/report", json=body, headers=_auth(token)) + assert r2.json()["duplicated"] is True + s = client.get("/api/v1/savings/summary", headers=_auth(token)).json() + assert s["order_count"] == 1 + + +def test_real_overrides_demo_seed(client) -> None: + """先触发 demo seeder(多笔),再上报一笔真实 → 统计切换为只算真实那笔。""" + token = _login(client, "13800002003") + demo = client.get("/api/v1/savings/summary", headers=_auth(token)).json() + assert demo["order_count"] > 1 # demo 是多笔 + + client.post( + "/api/v1/order/report", + json=_report_body( + client_event_id="evt-real", original_price_cents=5000, paid_amount_cents=4200 + ), + headers=_auth(token), + ) + s = client.get("/api/v1/savings/summary", headers=_auth(token)).json() + assert s["order_count"] == 1 # demo 被忽略 + assert s["total_saved_cents"] == 800 # 5000 − 4200 + + page = client.get("/api/v1/savings/records", headers=_auth(token)).json() + assert len(page["items"]) == 1 + assert page["items"][0]["saved_amount_cents"] == 800 + + +def test_order_report_requires_auth(client) -> None: + r = client.post("/api/v1/order/report", json=_report_body()) + assert r.status_code == 401