Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee6c2e4710 |
@@ -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.coupon import router as coupon_router
|
||||||
from app.api.v1.feedback import router as feedback_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.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.savings import router as savings_router
|
||||||
from app.api.v1.signin import router as signin_router
|
from app.api.v1.signin import router as signin_router
|
||||||
from app.api.v1.tasks import router as tasks_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(tasks_router)
|
||||||
app.include_router(savings_router)
|
app.include_router(savings_router)
|
||||||
app.include_router(ad_router)
|
app.include_router(ad_router)
|
||||||
|
app.include_router(order_router)
|
||||||
|
|
||||||
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
|
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
|
||||||
_media_root = Path(settings.MEDIA_ROOT)
|
_media_root = Path(settings.MEDIA_ROOT)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||||
from app.models.feedback import Feedback # 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.savings import SavingsRecord # noqa: F401
|
||||||
from app.models.signin import SigninRecord # noqa: F401
|
from app.models.signin import SigninRecord # noqa: F401
|
||||||
from app.models.task import UserTask # 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 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.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -17,6 +17,11 @@ from app.db.base import Base
|
|||||||
|
|
||||||
class SavingsRecord(Base):
|
class SavingsRecord(Base):
|
||||||
__tablename__ = "savings_record"
|
__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)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(
|
||||||
@@ -29,11 +34,30 @@ class SavingsRecord(Base):
|
|||||||
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
# 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」)
|
# 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」)
|
||||||
shop_name: 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 道菜」展开。
|
# 菜品名列表:PG 用 JSONB(可建 GIN 索引),SQLite 用 JSON(TEXT)。前 2 道直接展示,其余收进「还有 N 道菜」。
|
||||||
dishes: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
dishes: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list
|
||||||
|
)
|
||||||
# 来源:demo(演示) / compare(真实比价上报)
|
# 来源:demo(演示) / compare(真实比价上报)
|
||||||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
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.core.rewards import CN_TZ, cn_today
|
||||||
from app.models.savings import SavingsRecord
|
from app.models.savings import SavingsRecord
|
||||||
|
from app.schemas.order import OrderReportRequest
|
||||||
|
|
||||||
# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单
|
# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单
|
||||||
_DEMO_STREAK_DAYS = 9
|
_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())
|
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:
|
def ensure_seeded(db: Session, user_id: int) -> None:
|
||||||
"""该用户没有任何省钱记录时,幂等灌一批 demo 数据。"""
|
"""该用户没有任何省钱记录时,幂等灌一批 demo 数据。"""
|
||||||
exists = db.execute(
|
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:
|
def get_summary(db: Session, user_id: int) -> SavingsSummary:
|
||||||
ensure_seeded(db, user_id)
|
records = _effective_records(db, user_id)
|
||||||
records = _all_records(db, user_id)
|
|
||||||
total = sum(r.saved_amount_cents for r in records)
|
total = sum(r.saved_amount_cents for r in records)
|
||||||
count = len(records)
|
count = len(records)
|
||||||
avg = total // count if count else 0
|
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:
|
def get_battle(db: Session, user_id: int) -> SavingsBattle:
|
||||||
ensure_seeded(db, user_id)
|
records = _effective_records(db, user_id)
|
||||||
records = _all_records(db, user_id)
|
|
||||||
|
|
||||||
today = cn_today()
|
today = cn_today()
|
||||||
week_start = today - timedelta(days=today.weekday()) # 本周一
|
week_start = today - timedelta(days=today.weekday()) # 本周一
|
||||||
@@ -155,10 +174,20 @@ def _compute_beat_percent(db: Session, user_id: int) -> int:
|
|||||||
全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。
|
全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。
|
||||||
"""
|
"""
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(SavingsRecord.user_id, func.sum(SavingsRecord.saved_amount_cents))
|
select(
|
||||||
.group_by(SavingsRecord.user_id)
|
SavingsRecord.user_id,
|
||||||
|
SavingsRecord.source,
|
||||||
|
func.sum(SavingsRecord.saved_amount_cents),
|
||||||
|
).group_by(SavingsRecord.user_id, SavingsRecord.source)
|
||||||
).all()
|
).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}
|
others = {uid: t for uid, t in totals.items() if uid != user_id}
|
||||||
if not others:
|
if not others:
|
||||||
return 0
|
return 0
|
||||||
@@ -174,9 +203,12 @@ def list_records(
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
cursor: int | None = None,
|
cursor: int | None = None,
|
||||||
) -> tuple[list[SavingsRecord], int | None]:
|
) -> tuple[list[SavingsRecord], int | None]:
|
||||||
"""省钱明细分页(按 id 倒序,游标式)。"""
|
"""省钱明细分页(按 id 倒序,游标式)。有真实(compare)记录只列真实,否则 demo 兜底。"""
|
||||||
ensure_seeded(db, user_id)
|
|
||||||
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
|
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:
|
if cursor is not None:
|
||||||
stmt = stmt.where(SavingsRecord.id < cursor)
|
stmt = stmt.where(SavingsRecord.id < cursor)
|
||||||
stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit)
|
stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit)
|
||||||
@@ -184,3 +216,47 @@ def list_records(
|
|||||||
items = list(db.execute(stmt).scalars().all())
|
items = list(db.execute(stmt).scalars().all())
|
||||||
next_cursor = items[-1].id if len(items) == limit else None
|
next_cursor = items[-1].id if len(items) == limit else None
|
||||||
return items, next_cursor
|
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)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
order_amount_cents: int
|
order_amount_cents: int # 实付金额(分)
|
||||||
saved_amount_cents: int
|
saved_amount_cents: int # 省下(分)
|
||||||
|
original_price_cents: int | None = None # 源平台原价(分);demo 行为空
|
||||||
platform: str | None = None
|
platform: str | None = None
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
shop_name: str | None = None
|
shop_name: str | None = None
|
||||||
dishes: list[str] = []
|
dishes: list[str] = []
|
||||||
|
pay_channel: str | None = None # wechat/alipay;demo 行为空
|
||||||
|
source_platform_name: str | None = None # 源平台名,如 美团
|
||||||
created_at: datetime
|
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