From e69788eb41ff2ed8aabd8a8ddfbf2b1ddd90bd92 Mon Sep 17 00:00:00 2001 From: zhangxianze Date: Wed, 10 Jun 2026 14:44:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=B8=8A=E6=8A=A5=E6=9B=B4?= =?UTF-8?q?=E4=BD=8E=E4=BB=B7=E4=BA=BA=E5=B7=A5=E5=AE=A1=E6=A0=B8=EF=BC=88?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E5=8F=911000=E9=87=91=E5=B8=81/=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E5=A1=AB=E7=90=86=E7=94=B1=EF=BC=89=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运营后台审核用户上报的「某平台比我们算的最低价更便宜」+截图。 - routers/price_report.py:列表 + /summary、approve|reject(require operator) - repositories:list_price_reports + price_report_summary(queries)、review_price_report(mutations) - core/rewards.py:PRICE_REPORT_REWARD_COINS=1000(通过即发,grant_coins 由 router 同事务调) - admin/main.py:注册 price_report_router 跨端配套:前端 admin-web feat/price-report-review;端上金币刷新 android feat/price-report-review。 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: xianze Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/33 Co-authored-by: zhangxianze Co-committed-by: zhangxianze --- app/admin/main.py | 2 + app/admin/repositories/mutations.py | 31 +++++++++ app/admin/repositories/queries.py | 32 +++++++++ app/admin/routers/price_report.py | 103 ++++++++++++++++++++++++++++ app/admin/schemas/price_report.py | 46 +++++++++++++ app/core/rewards.py | 6 ++ 6 files changed, 220 insertions(+) create mode 100644 app/admin/routers/price_report.py create mode 100644 app/admin/schemas/price_report.py diff --git a/app/admin/main.py b/app/admin/main.py index 74a06bb..c68b9bf 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -21,6 +21,7 @@ from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router +from app.admin.routers.price_report import router as price_report_router from app.admin.routers.users import router as users_router from app.admin.routers.wallet import router as wallet_router from app.admin.routers.withdraw import router as withdraw_router @@ -80,6 +81,7 @@ admin_app.include_router(ops_marquee_seed_router) admin_app.include_router(users_router) admin_app.include_router(wallet_router) admin_app.include_router(withdraw_router) +admin_app.include_router(price_report_router) admin_app.include_router(feedback_router) admin_app.include_router(admins_router) admin_app.include_router(audit_router) diff --git a/app/admin/repositories/mutations.py b/app/admin/repositories/mutations.py index 14e3cba..9effde4 100644 --- a/app/admin/repositories/mutations.py +++ b/app/admin/repositories/mutations.py @@ -8,9 +8,13 @@ set_user_status / update_feedback_status 支持 commit=False,让 router 把"业 """ from __future__ import annotations +from datetime import datetime + from sqlalchemy.orm import Session +from app.core.rewards import CN_TZ from app.models.feedback import Feedback +from app.models.price_report import PriceReport from app.models.user import User @@ -34,3 +38,30 @@ def update_feedback_status( else: db.flush() return feedback + + +def review_price_report( + db: Session, + report: PriceReport, + *, + status: str, + reward_coins: int | None = None, + reject_reason: str | None = None, + commit: bool = True, +) -> PriceReport: + """审核上报:置 approved/rejected + 记审核时间;通过填 reward_coins、拒绝填 reject_reason。 + + 发金币(wallet.grant_coins)不在这里——由 router 在同一事务里调,涉钱逻辑不重写(见模块头注释)。 + """ + report.status = status + if reward_coins is not None: + report.reward_coins = reward_coins + if reject_reason is not None: + report.reject_reason = reject_reason + report.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) # 北京 wall-clock,同 created_at + if commit: + db.commit() + db.refresh(report) + else: + db.flush() + return report diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 9601653..ce88603 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import Session from app.models.admin import AdminAuditLog from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback +from app.models.price_report import PriceReport from app.models.user import User from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder @@ -395,3 +396,34 @@ def get_user_overview(db: Session, user_id: int) -> dict | None: ).scalar_one(), "feedback_total": _count(Feedback, Feedback.user_id == user_id), } + + +def list_price_reports( + db: Session, + *, + status: str | None = None, + user_id: int | None = None, + limit: int = 20, + cursor: int | None = None, +) -> tuple[list[PriceReport], int | None]: + """上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。""" + stmt = select(PriceReport) + if status: + stmt = stmt.where(PriceReport.status == status) + if user_id is not None: + stmt = stmt.where(PriceReport.user_id == user_id) + return cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor) + + +def price_report_summary(db: Session) -> dict: + """上报审核台各状态计数(待审核/已通过/已拒绝/合计)。""" + rows = db.execute( + select(PriceReport.status, func.count(PriceReport.id)).group_by(PriceReport.status) + ).all() + by_status = {status: int(count) for status, count in rows} + return { + "pending": by_status.get("pending", 0), + "approved": by_status.get("approved", 0), + "rejected": by_status.get("rejected", 0), + "total": sum(by_status.values()), + } diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py new file mode 100644 index 0000000..b29f271 --- /dev/null +++ b/app/admin/routers/price_report.py @@ -0,0 +1,103 @@ +"""admin 上报更低价审核:列表 + 统计(读)+ 通过(发金币)/拒绝(写,带审计)。 + +数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台 +对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS): +改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins), +绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.repositories import mutations, queries +from app.admin.schemas.common import CursorPage, OkResponse +from app.admin.schemas.price_report import ( + PriceReportOut, + PriceReportRejectRequest, + PriceReportSummary, +) +from app.core.rewards import PRICE_REPORT_REWARD_COINS +from app.models.admin import AdminUser +from app.models.price_report import PriceReport +from app.repositories import wallet as wallet_repo + +router = APIRouter( + prefix="/admin/api/price-reports", + tags=["admin-price-report"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)") +def list_price_reports( + db: AdminDb, + status: Annotated[str | None, Query()] = None, + user_id: Annotated[int | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[PriceReportOut]: + items, next_cursor = queries.list_price_reports( + db, status=status, user_id=user_id, limit=limit, cursor=cursor, + ) + return CursorPage( + items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor, + ) + + +@router.get("/summary", response_model=PriceReportSummary, summary="上报审核统计(各状态计数)") +def price_report_summary(db: AdminDb) -> PriceReportSummary: + return PriceReportSummary.model_validate(queries.price_report_summary(db)) + + +@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)") +def approve_price_report( + report_id: int, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OkResponse: + rep = db.get(PriceReport, report_id) + if rep is None: + raise HTTPException(status_code=404, detail="上报记录不存在") + if rep.status != "pending": + raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") + coins = PRICE_REPORT_REWARD_COINS + # 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账 + mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False) + wallet_repo.grant_coins( + db, rep.user_id, coins, + biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过", + ) + write_audit( + db, admin, action="price_report.approve", target_type="price_report", target_id=report_id, + detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() + + +@router.post("/{report_id}/reject", response_model=OkResponse, summary="拒绝上报") +def reject_price_report( + report_id: int, + body: PriceReportRejectRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OkResponse: + rep = db.get(PriceReport, report_id) + if rep is None: + raise HTTPException(status_code=404, detail="上报记录不存在") + if rep.status != "pending": + raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") + reason = body.reason.strip() + mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False) + write_audit( + db, admin, action="price_report.reject", target_type="price_report", target_id=report_id, + detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() diff --git a/app/admin/schemas/price_report.py b/app/admin/schemas/price_report.py new file mode 100644 index 0000000..30ea8ad --- /dev/null +++ b/app/admin/schemas/price_report.py @@ -0,0 +1,46 @@ +"""admin 上报更低价审核 schemas。""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class PriceReportOut(BaseModel): + """admin 列表项:price_report 全字段(审核要看的快照 + 截图 + 状态 + 奖励)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + comparison_record_id: int | None = None + # 选中比价记录的快照 + store_name: str | None = None + dish_summary: str | None = None + 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] = [] + # 审核 + status: str + reject_reason: str | None = None + reward_coins: int | None = None + reviewed_at: datetime | None = None + created_at: datetime + + +class PriceReportRejectRequest(BaseModel): + reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到") + + +class PriceReportSummary(BaseModel): + """审核台顶部各状态计数。""" + + pending: int + approved: int + rejected: int + total: int diff --git a/app/core/rewards.py b/app/core/rewards.py index 66449e7..ac5c4be 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -77,6 +77,12 @@ def record_milestone_reward(milestone: int) -> int: return RECORD_MILESTONES[milestone - 1] +# ===== 上报更低价(人工审核通过发固定金币)===== +# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。 +# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。 +PRICE_REPORT_REWARD_COINS: int = 1000 + + # ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)===== # 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。 INVITE_INVITER_COINS: int = 10000