Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcfac7ad07 |
@@ -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 ###
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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/<file>`)。"""
|
||||
return _save_image("price_report", user_id, data)
|
||||
|
||||
|
||||
def delete_avatar(url: str | None) -> None:
|
||||
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
|
||||
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"<PriceReport id={self.id} user_id={self.user_id} status={self.status}>"
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user