From 288766443ab15fd381de05525c4ebddf75b3e0cd Mon Sep 17 00:00:00 2001 From: zhangxianze Date: Mon, 1 Jun 2026 21:29:52 +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=20M1(sav?= =?UTF-8?q?ings=5Frecord=20=E5=94=AF=E4=B8=80=E7=9C=9F=E7=9B=B8=E8=A1=A8?= =?UTF-8?q?=20+=20/order/report=20+=20demo=20=E5=85=9C=E5=BA=95)=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从最新 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) --------- Co-authored-by: xianze Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/10 Co-authored-by: zhangxianze Co-committed-by: zhangxianze --- alembic/versions/savings_report_fields.py | 49 ++++++++++ app/api/v1/order.py | 25 ++++++ app/main.py | 2 + app/models/savings.py | 30 ++++++- app/repositories/savings.py | 94 +++++++++++++++++-- app/schemas/order.py | 30 +++++++ app/schemas/welfare.py | 7 +- tests/test_order_savings.py | 105 ++++++++++++++++++++++ 8 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 alembic/versions/savings_report_fields.py create mode 100644 app/api/v1/order.py create mode 100644 app/schemas/order.py create mode 100644 tests/test_order_savings.py diff --git a/alembic/versions/savings_report_fields.py b/alembic/versions/savings_report_fields.py new file mode 100644 index 0000000..77f0f6b --- /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: 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') diff --git a/app/api/v1/order.py b/app/api/v1/order.py new file mode 100644 index 0000000..c2f9bb3 --- /dev/null +++ b/app/api/v1/order.py @@ -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, + ) 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/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/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..9d7af17 --- /dev/null +++ b/app/schemas/order.py @@ -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 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