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/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/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/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/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