feat(记账): 比价省钱记账后端 M1(savings_record 唯一真相表 + /order/report + demo 兜底) (#10)

从最新 origin/main 重拉的干净分支,只含本人记账改动,不含 marco 的归因订单(order_record)——后者是另一条未合并分支 feat/user-order-records 的功能,此前被误用 git add -A 和记账混进了 feat/savings-accounting-m1。本分支不带它:savings_record 升级为唯一真相表(新增原价/比价价/支付渠道/源平台/幂等键等列,迁移 savings_report_fields 直接挂 f01db5d77dac、不经 order_record_table);/order/report 改写 savings_record(source='compare'),省额=源平台原价−实付;展示 /api/v1/savings/* 真实优先 demo 兜底。无 order_record 的 model/repo/迁移/路由。alembic 单一 head + pytest 54 passed。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: xianze <ze@192.168.0.138>
Reviewed-on: #10
Co-authored-by: zhangxianze <zhangxianze@wonderable.ai>
Co-committed-by: zhangxianze <zhangxianze@wonderable.ai>
This commit was merged in pull request #10.
This commit is contained in:
zhangxianze
2026-06-01 21:29:52 +08:00
committed by marco
parent a114a0f3f2
commit 288766443a
8 changed files with 328 additions and 14 deletions
+49
View File
@@ -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: f01db5d77dac
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] = 'f01db5d77dac'
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')
+25
View File
@@ -0,0 +1,25 @@
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.repositories import savings as crud_savings
from app.schemas.order import 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')。
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,
)
+2
View File
@@ -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)
+27 -3
View File
@@ -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
)
+85 -9
View File
@@ -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
+30
View File
@@ -0,0 +1,30 @@
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
+5 -2
View File
@@ -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
+105
View File
@@ -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