From 4506677483769b8ca6438016b0bf06b94330ef19 Mon Sep 17 00:00:00 2001 From: zhangxianze Date: Sat, 6 Jun 2026 00:42:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(report):=20=E4=B8=8A=E6=8A=A5=E6=9B=B4?= =?UTF-8?q?=E4=BD=8E=E4=BB=B7=E5=90=8E=E7=AB=AF=E2=80=94=E2=80=94price=5Fr?= =?UTF-8?q?eport=20=E8=A1=A8=20+=20=E6=8F=90=E4=BA=A4/=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xianze Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/13 Co-authored-by: zhangxianze Co-committed-by: zhangxianze --- ...bddde4ea_新增_price_report_上报更低价表.py | 65 ++++++++ ...1a9f4d3_comparison_record_best_deeplink.py | 32 ++++ app/api/v1/report.py | 145 ++++++++++++++++++ app/core/media.py | 5 + app/main.py | 2 + app/models/__init__.py | 1 + app/models/comparison.py | 3 + app/models/price_report.py | 67 ++++++++ app/repositories/comparison.py | 30 +++- app/repositories/report.py | 53 +++++++ app/schemas/compare_record.py | 6 + app/schemas/report.py | 57 +++++++ 12 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/9258bddde4ea_新增_price_report_上报更低价表.py create mode 100644 alembic/versions/b7e2c1a9f4d3_comparison_record_best_deeplink.py create mode 100644 app/api/v1/report.py create mode 100644 app/models/price_report.py create mode 100644 app/repositories/report.py create mode 100644 app/schemas/report.py diff --git a/alembic/versions/9258bddde4ea_新增_price_report_上报更低价表.py b/alembic/versions/9258bddde4ea_新增_price_report_上报更低价表.py new file mode 100644 index 0000000..6aacd27 --- /dev/null +++ b/alembic/versions/9258bddde4ea_新增_price_report_上报更低价表.py @@ -0,0 +1,65 @@ +"""新增 price_report 上报更低价表 + +Revision ID: 9258bddde4ea +Revises: ad60a1b2c3d4 +Create Date: 2026-06-05 10:15:51.508598 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9258bddde4ea' +down_revision: Union[str, Sequence[str], None] = 'ad60a1b2c3d4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('price_report', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('comparison_record_id', sa.Integer(), nullable=True), + sa.Column('store_name', sa.String(length=128), nullable=True), + sa.Column('dish_summary', sa.String(length=256), nullable=True), + sa.Column('original_platform_id', sa.String(length=32), nullable=True), + sa.Column('original_platform_name', sa.String(length=32), nullable=True), + sa.Column('original_price_cents', sa.Integer(), nullable=True), + sa.Column('reported_platform_id', sa.String(length=32), nullable=False), + sa.Column('reported_platform_name', sa.String(length=32), nullable=False), + sa.Column('reported_price_cents', sa.Integer(), nullable=False), + sa.Column('images', sa.JSON(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('reject_reason', sa.String(length=256), nullable=True), + sa.Column('reward_coins', sa.Integer(), nullable=True), + sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['comparison_record_id'], ['comparison_record.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('price_report', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_price_report_comparison_record_id'), ['comparison_record_id'], unique=False) + batch_op.create_index(batch_op.f('ix_price_report_created_at'), ['created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_price_report_status'), ['status'], unique=False) + batch_op.create_index(batch_op.f('ix_price_report_user_id'), ['user_id'], unique=False) + # 注:autogenerate 另检测到「删除 order_record 表」——那是已降级为审计的历史表 + # (model 已移除、库表保留),与本次无关,手动剔除,避免误删他人审计数据。 + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + # (对称:upgrade 未删 order_record,downgrade 也不重建它) + with op.batch_alter_table('price_report', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_price_report_user_id')) + batch_op.drop_index(batch_op.f('ix_price_report_status')) + batch_op.drop_index(batch_op.f('ix_price_report_created_at')) + batch_op.drop_index(batch_op.f('ix_price_report_comparison_record_id')) + + op.drop_table('price_report') + # ### end Alembic commands ### diff --git a/alembic/versions/b7e2c1a9f4d3_comparison_record_best_deeplink.py b/alembic/versions/b7e2c1a9f4d3_comparison_record_best_deeplink.py new file mode 100644 index 0000000..abf8788 --- /dev/null +++ b/alembic/versions/b7e2c1a9f4d3_comparison_record_best_deeplink.py @@ -0,0 +1,32 @@ +"""comparison_record 加 best_deeplink(再次比价直达商家深链) + +Revision ID: b7e2c1a9f4d3 +Revises: 9258bddde4ea +Create Date: 2026-06-05 + +「再次比价」要直达上次最低价平台的商家/商品页。深链(link)在比价时客户端已从剪贴板采到 +(collectedLinks[best_index]),本列把它落库;再次比价时写剪贴板 + launch 该平台 App 直达。 +旧记录无此列值(None) → 前端降级为打开对应 App 首页。 + +手写迁移(只加一列):autogenerate 会误报"删 order_record"(降级为审计的历史表,model 已移除、 +库表保留),手写规避该噪音。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "b7e2c1a9f4d3" +down_revision: Union[str, Sequence[str], None] = "9258bddde4ea" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("comparison_record", schema=None) as batch_op: + batch_op.add_column(sa.Column("best_deeplink", sa.String(length=1024), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("comparison_record", schema=None) as batch_op: + batch_op.drop_column("best_deeplink") diff --git a/app/api/v1/report.py b/app/api/v1/report.py new file mode 100644 index 0000000..0be93d8 --- /dev/null +++ b/app/api/v1/report.py @@ -0,0 +1,145 @@ +"""上报更低价 endpoint。 + +路由前缀 `/api/v1/report`,需 Bearer 鉴权(上报绑登录用户)。 + POST / 提交上报(multipart:comparison_record_id / reported_platform_id / + reported_price(元) + images 1~4 张) + GET /records 上报记录列表(?status= pending/approved/rejected 可选筛选) + +要点: +- 截图复用 [app.core.media] 落盘到 /media/price_report/。 +- 原最低价由 comparison_record_id 反查比价记录 best_*(不信任客户端传的快照)。 +- 校验(D):上报价必须 < 原最低价,否则 400。 +- 提交一律 status=pending;通过发奖励是人工审核后台动作,不在此。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile + +from app.api.deps import CurrentUser, DbSession +from app.core import media +from app.models.comparison import ComparisonRecord +from app.repositories import report as report_repo +from app.schemas.report import ( + ReportRecordCounts, + ReportRecordOut, + ReportRecordsOut, + ReportSubmitOut, +) + +logger = logging.getLogger("shagua.report") + +router = APIRouter(prefix="/api/v1/report", tags=["report"]) + +_MAX_IMAGES = 4 +_VALID_STATUS = ("pending", "approved", "rejected") + +# 上报平台(canonical id → 展示名),与原型 fb-platform-chip data-pf 一致 +_PLATFORM_NAMES = { + "meituan-waimai": "美团外卖", + "jd-waimai": "京东外卖", + "taobao-shanguang": "淘宝闪购", +} + + +def _dish_summary(items: list | None) -> str | None: + """把比价记录 items[{name,qty,...}] 拼成菜品摘要文案。""" + if not items: + return None + names = [str(it.get("name", "")).strip() for it in items if isinstance(it, dict)] + names = [n for n in names if n] + return "、".join(names) if names else None + + +@router.post("", response_model=ReportSubmitOut, summary="提交上报更低价") +async def submit_report( + user: CurrentUser, + db: DbSession, + comparison_record_id: int = Form(...), + reported_platform_id: str = Form(...), + reported_price: str = Form(..., description="用户填的更低价(元)"), + images: list[UploadFile] = File(default=[]), +) -> ReportSubmitOut: + # 平台 + reported_platform_id = reported_platform_id.strip() + platform_name = _PLATFORM_NAMES.get(reported_platform_id) + if platform_name is None: + raise HTTPException(status_code=400, detail="不支持的上报平台") + + # 价格(元 → 分) + try: + price_yuan = float(reported_price) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="价格格式不正确") from None + if price_yuan <= 0: + raise HTTPException(status_code=400, detail="价格必须大于 0") + reported_price_cents = round(price_yuan * 100) + + # 反查比价记录(必须属于本人)→ 取原最低价快照 + rec = db.get(ComparisonRecord, comparison_record_id) + if rec is None or rec.user_id != user.id: + raise HTTPException(status_code=404, detail="比价记录不存在") + original_price_cents = rec.best_price_cents + + # D:上报价必须低于原最低价 + if original_price_cents is not None and reported_price_cents >= original_price_cents: + raise HTTPException( + status_code=400, + detail=f"上报价需低于原最低价 ¥{original_price_cents / 100:.2f}", + ) + + # 截图(至少 1 张,最多 4 张) + files = [f for f in (images or []) if f is not None and f.filename] + if not files: + raise HTTPException(status_code=400, detail="请至少上传一张截图证明") + if len(files) > _MAX_IMAGES: + raise HTTPException(status_code=400, detail=f"最多上传 {_MAX_IMAGES} 张图片") + urls: list[str] = [] + for f in files: + data = await f.read() + try: + urls.append(media.save_report_image(user.id, data)) + except media.MediaError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + rep = report_repo.create_report( + db, + user_id=user.id, + comparison_record_id=comparison_record_id, + store_name=rec.store_name, + dish_summary=_dish_summary(rec.items), + original_platform_id=rec.best_platform_id, + original_platform_name=rec.best_platform_name, + original_price_cents=original_price_cents, + reported_platform_id=reported_platform_id, + reported_platform_name=platform_name, + reported_price_cents=reported_price_cents, + images=urls, + ) + logger.info("price_report id=%d user_id=%d images=%d", rep.id, user.id, len(urls)) + return ReportSubmitOut.model_validate(rep) + + +@router.get("/records", response_model=ReportRecordsOut, summary="上报记录列表") +def list_report_records( + user: CurrentUser, + db: DbSession, + status: str | None = None, +) -> ReportRecordsOut: + status = (status or "").strip() or None + if status and status not in _VALID_STATUS: + raise HTTPException(status_code=400, detail="无效的状态筛选") + rows = report_repo.list_reports(db, user.id, status) + # counts 始终基于全量(不受 status 筛选影响),供前端 chip 计数 + all_rows = rows if status is None else report_repo.list_reports(db, user.id, None) + counts = ReportRecordCounts( + all=len(all_rows), + pending=sum(1 for r in all_rows if r.status == "pending"), + approved=sum(1 for r in all_rows if r.status == "approved"), + rejected=sum(1 for r in all_rows if r.status == "rejected"), + ) + return ReportRecordsOut( + records=[ReportRecordOut.model_validate(r) for r in rows], + counts=counts, + ) diff --git a/app/core/media.py b/app/core/media.py index 7f3ae05..e7e2611 100644 --- a/app/core/media.py +++ b/app/core/media.py @@ -62,6 +62,11 @@ def save_feedback_image(user_id: int, data: bytes) -> str: return _save_image("feedback", user_id, data) +def save_report_image(user_id: int, data: bytes) -> str: + """保存上报更低价截图,返回相对 URL(`/media/price_report/`)。""" + return _save_image("price_report", user_id, data) + + def delete_avatar(url: str | None) -> None: """删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。""" prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/" diff --git a/app/main.py b/app/main.py index 6d6a238..43d83f1 100644 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,7 @@ 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.report import router as report_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 @@ -86,6 +87,7 @@ app.include_router(tasks_router) app.include_router(savings_router) app.include_router(ad_router) app.include_router(order_router) +app.include_router(report_router) # 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。 _media_root = Path(settings.MEDIA_ROOT) diff --git a/app/models/__init__.py b/app/models/__init__.py index 52dd205..1a68fc9 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 from app.models.comparison import ComparisonRecord # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.feedback import Feedback # noqa: F401 +from app.models.price_report import PriceReport # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401 from app.models.signin import SigninRecord # noqa: F401 from app.models.task import UserTask # noqa: F401 diff --git a/app/models/comparison.py b/app/models/comparison.py index c351e94..d1de520 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -64,6 +64,9 @@ class ComparisonRecord(Base): best_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True) best_platform_name: Mapped[str | None] = mapped_column(String(32), nullable=True) best_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 最优平台的商家/商品深链(客户端比价时从剪贴板采到 = collectedLinks[best_index]); + # 「再次比价」写剪贴板 + launch 该平台 App 直达。仅 2026-06 起新比价有,旧记录为 None。 + best_deeplink: Mapped[str | None] = mapped_column(String(1024), nullable=True) # 源价 - 最优价(可为 0 / 负:源平台本来就最便宜时没省到) saved_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) # 源平台就是最便宜的一家(= 这次没省到钱) diff --git a/app/models/price_report.py b/app/models/price_report.py new file mode 100644 index 0000000..af04cfd --- /dev/null +++ b/app/models/price_report.py @@ -0,0 +1,67 @@ +"""上报更低价记录表(price_report)。 + +用户在「比价记录」里选一条记录,上报「某平台有比我们算出的最低价更低的价格」,带截图证明, +人工审核通过奖励金币。与 feedback(自由文本反馈)不同:本表结构化——关联具体比价记录、 +原最低价 vs 上报价、审核状态、奖励。 + +- 原最低价快照(original_*)由后端按 comparison_record_id 反查比价记录的 best_* 冗余填入, + 避免被关联记录后续删/改后对不上。 +- 价格统一存「分」(cents),与 comparison_record / savings_record 一致。 +- status: pending(审核中)/ approved(已通过)/ rejected(未通过),对齐前端筛选与状态徽标。 +- images: 截图相对 URL 列表(/media/price_report/...,复用 app.core.media)。 +- 奖励发放(通过 → 钱包 +reward_coins)是人工审核后台动作,提交时一律 pending,本表只留字段。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PriceReport(Base): + __tablename__ = "price_report" + + 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 + ) + # 选中的那条比价记录(可空:记录被删后仍保留上报历史) + comparison_record_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("comparison_record.id"), index=True, nullable=True + ) + + # ===== 选中比价记录的快照(后端反查 comparison_record 填入,冗余存) ===== + store_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + dish_summary: Mapped[str | None] = mapped_column(String(256), nullable=True) + original_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True) + original_platform_name: Mapped[str | None] = mapped_column(String(32), nullable=True) + original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # ===== 用户上报的更低价 ===== + reported_platform_id: Mapped[str] = mapped_column(String(32), nullable=False) + reported_platform_name: Mapped[str] = mapped_column(String(32), nullable=False) + reported_price_cents: Mapped[int] = mapped_column(Integer, nullable=False) + # 截图证明 URL 列表(相对路径,如 ["/media/price_report/u1_ab.jpg"]) + images: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + + # ===== 审核(人工后台) ===== + status: Mapped[str] = mapped_column( + String(16), nullable=False, default="pending", index=True + ) + reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True) + reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True) + reviewed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 3a5e299..f233c4f 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -9,6 +9,7 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.comparison import ComparisonRecord +from app.models.savings import SavingsRecord from app.schemas.compare_record import ComparisonRecordIn @@ -79,6 +80,7 @@ def upsert_record( source_platform_name=payload.source_platform_name, source_package=payload.source_package, information=payload.information, + best_deeplink=payload.best_deeplink, total_dish_count=payload.total_dish_count, skipped_dish_count=payload.skipped_dish_count, items=[it.model_dump(exclude_none=True) for it in payload.items], @@ -109,6 +111,23 @@ def upsert_record( return rec +def _ordered_shop_names(db: Session, user_id: int) -> set[str]: + """该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」。 + + 只认 compare(归因命中后真实上报),demo 演示数据不算。下单上报不带 trace_id, + 只能按店名对齐——两边店名同源(都来自比价意图识别阶段的门店名 query),精确相等即视为同店。 + 语义=店级:同一家店比价过多次,这些记录会一并标「已下单」。 + """ + rows = db.execute( + select(SavingsRecord.shop_name).where( + SavingsRecord.user_id == user_id, + SavingsRecord.source == "compare", + SavingsRecord.shop_name.is_not(None), + ) + ).scalars().all() + return {s for s in rows if s} + + def list_records( db: Session, user_id: int, @@ -116,14 +135,21 @@ def list_records( limit: int = 20, cursor: int | None = None, ) -> tuple[list[ComparisonRecord], int | None]: - """比价记录分页(按 id 倒序,游标式)。""" + """比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记(瞬态,不写库)。""" stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id) if cursor is not None: stmt = stmt.where(ComparisonRecord.id < cursor) - stmt = stmt.order_by(ComparisonRecord.id.desc()).limit(limit) + stmt = stmt.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc()).limit(limit) items = list(db.execute(stmt).scalars().all()) next_cursor = items[-1].id if len(items) == limit else None + + # 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。 + # ordered 非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。 + ordered_shops = _ordered_shop_names(db, user_id) + for it in items: + it.ordered = bool(it.store_name and it.store_name in ordered_shops) + return items, next_cursor diff --git a/app/repositories/report.py b/app/repositories/report.py new file mode 100644 index 0000000..ef87bb1 --- /dev/null +++ b/app/repositories/report.py @@ -0,0 +1,53 @@ +"""price_report 表读写。""" +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.price_report import PriceReport + + +def create_report( + db: Session, + *, + user_id: int, + comparison_record_id: int | None, + store_name: str | None, + dish_summary: str | None, + original_platform_id: str | None, + original_platform_name: str | None, + original_price_cents: int | None, + reported_platform_id: str, + reported_platform_name: str, + reported_price_cents: int, + images: list[str], +) -> PriceReport: + rep = PriceReport( + user_id=user_id, + comparison_record_id=comparison_record_id, + store_name=store_name, + dish_summary=dish_summary, + original_platform_id=original_platform_id, + original_platform_name=original_platform_name, + original_price_cents=original_price_cents, + reported_platform_id=reported_platform_id, + reported_platform_name=reported_platform_name, + reported_price_cents=reported_price_cents, + images=images, + status="pending", + ) + db.add(rep) + db.commit() + db.refresh(rep) + return rep + + +def list_reports( + db: Session, user_id: int, status: str | None = None +) -> list[PriceReport]: + """该用户的上报记录,按时间倒序;status 非空时按状态筛选。""" + stmt = select(PriceReport).where(PriceReport.user_id == user_id) + if status: + stmt = stmt.where(PriceReport.status == status) + stmt = stmt.order_by(PriceReport.created_at.desc(), PriceReport.id.desc()) + return list(db.execute(stmt).scalars().all()) diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index d431d46..1d2cb22 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -62,6 +62,8 @@ class ComparisonRecordIn(BaseModel): information: str | None = Field(None, description="done 帧文案,留存备查") # 不传则服务端按 comparison_results 派生(有非源有效价=success,否则 failed) status: str | None = Field(None, description="success / failed,可不传由服务端派生") + # 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用 + best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达") # ===== 读取出参 ===== @@ -81,6 +83,7 @@ class ComparisonRecordOut(BaseModel): best_platform_id: str | None = None best_platform_name: str | None = None best_price_cents: int | None = None + best_deeplink: str | None = None saved_amount_cents: int | None = None is_source_best: bool | None = None store_name: str | None = None @@ -91,6 +94,9 @@ class ComparisonRecordOut(BaseModel): items: list = [] comparison_results: list = [] skipped_dish_names: list = [] + # 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。 + # 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。 + ordered: bool = False created_at: datetime diff --git a/app/schemas/report.py b/app/schemas/report.py new file mode 100644 index 0000000..6394656 --- /dev/null +++ b/app/schemas/report.py @@ -0,0 +1,57 @@ +"""上报更低价 响应 schema。 + +请求是 multipart 表单(comparison_record_id / reported_platform_id / reported_price / images), +在路由里直接校验,不单独建请求 schema(同 feedback)。价格对外统一「分」(cents)。 +""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class ReportSubmitOut(BaseModel): + """提交上报后的回执。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + status: str + created_at: datetime + + +class ReportRecordOut(BaseModel): + """一条上报记录(「上报记录」列表用)。价格单位:分。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + store_name: str | None = None + dish_summary: str | None = None + # 原最低价(上报前系统给出的最低,反查比价记录 best_*) + original_platform_id: str | None = None + original_platform_name: str | None = None + original_price_cents: int | None = None + # 用户上报的更低价 + reported_platform_id: str + reported_platform_name: str + reported_price_cents: int + images: list[str] = Field(default_factory=list) + status: str # pending / approved / rejected + reject_reason: str | None = None + reward_coins: int | None = None + created_at: datetime + + +class ReportRecordCounts(BaseModel): + """四态计数,供前端筛选 chip 显示(全部/审核中/已通过/未通过)。""" + + all: int = 0 + pending: int = 0 + approved: int = 0 + rejected: int = 0 + + +class ReportRecordsOut(BaseModel): + records: list[ReportRecordOut] + counts: ReportRecordCounts