feat(记账): 比价省钱记账后端 + 此前未提交的归因订单基础
本次(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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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")
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
+27
-3
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user