diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 987c76b..28b5b71 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -12,6 +12,10 @@ from app.admin.repositories import mutations, queries from app.admin.schemas.common import CursorPage, OkResponse from app.admin.schemas.feedback import ( FeedbackApproveRequest, + FeedbackBulkApproveRequest, + FeedbackBulkItemResult, + FeedbackBulkRejectRequest, + FeedbackBulkResult, FeedbackOut, FeedbackRejectRequest, FeedbackSummary, @@ -33,6 +37,123 @@ def _ensure_pending(fb: Feedback) -> None: raise HTTPException(status_code=400, detail="反馈已审核") +def _approve_feedback( + db: AdminDb, + admin: AdminUser, + feedback_id: int, + payload: FeedbackApproveRequest | FeedbackBulkApproveRequest, + ip: str, + *, + bulk: bool = False, +) -> FeedbackOut: + fb = db.get(Feedback, feedback_id, with_for_update=True) + if fb is None: + raise HTTPException(status_code=404, detail="反馈不存在") + _ensure_pending(fb) + + before = fb.status + mutations.review_feedback( + db, + fb, + status="adopted", + reward_coins=payload.reward_coins, + review_note=payload.note, + admin_reply=payload.reply, + reviewed_by_admin_id=admin.id, + commit=False, + ) + wallet_repo.grant_coins( + db, + fb.user_id, + payload.reward_coins, + biz_type="feedback_reward", + ref_id=str(fb.id), + remark="意见反馈被采纳", + ) + detail = { + "before": before, + "after": "adopted", + "reward_coins": payload.reward_coins, + "note": payload.note, + "reply": payload.reply, + } + if bulk: + detail["bulk"] = True + write_audit( + db, + admin, + action="feedback.approve", + target_type="feedback", + target_id=feedback_id, + detail=detail, + ip=ip, + commit=False, + ) + db.commit() + db.refresh(fb) + out = FeedbackOut.model_validate(fb) + notification_events.notify_feedback_reward(db, fb) + return out + + +def _reject_feedback( + db: AdminDb, + admin: AdminUser, + feedback_id: int, + payload: FeedbackRejectRequest | FeedbackBulkRejectRequest, + ip: str, + *, + bulk: bool = False, +) -> FeedbackOut: + fb = db.get(Feedback, feedback_id, with_for_update=True) + if fb is None: + raise HTTPException(status_code=404, detail="反馈不存在") + _ensure_pending(fb) + + before = fb.status + mutations.review_feedback( + db, + fb, + status="rejected", + reject_reason=payload.reason, + review_note=payload.note, + admin_reply=payload.reply, + reviewed_by_admin_id=admin.id, + commit=False, + ) + detail = { + "before": before, + "after": "rejected", + "reason": payload.reason, + "note": payload.note, + "reply": payload.reply, + } + if bulk: + detail["bulk"] = True + write_audit( + db, + admin, + action="feedback.reject", + target_type="feedback", + target_id=feedback_id, + detail=detail, + ip=ip, + commit=False, + ) + db.commit() + db.refresh(fb) + out = FeedbackOut.model_validate(fb) + notification_events.notify_feedback_reply(db, fb) + return out + + +def _bulk_result(items: list[FeedbackBulkItemResult]) -> FeedbackBulkResult: + success = sum(1 for item in items if item.ok) + return FeedbackBulkResult( + total=len(items), success=success, failed=len(items) - success, items=items, + ) + + @router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表") def list_feedbacks( db: AdminDb, @@ -73,6 +194,50 @@ def feedback_summary(db: AdminDb) -> FeedbackSummary: return FeedbackSummary.model_validate(queries.feedback_summary(db)) +@router.post("/bulk/approve", response_model=FeedbackBulkResult, summary="批量采纳反馈并发金币") +def bulk_approve_feedbacks( + body: FeedbackBulkApproveRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> FeedbackBulkResult: + results: list[FeedbackBulkItemResult] = [] + ip = get_client_ip(request) + for feedback_id in body.ids: + try: + out = _approve_feedback(db, admin, feedback_id, body, ip, bulk=True) + results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status)) + except HTTPException as exc: + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常")) + return _bulk_result(results) + + +@router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈") +def bulk_reject_feedbacks( + body: FeedbackBulkRejectRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> FeedbackBulkResult: + results: list[FeedbackBulkItemResult] = [] + ip = get_client_ip(request) + for feedback_id in body.ids: + try: + out = _reject_feedback(db, admin, feedback_id, body, ip, bulk=True) + results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status)) + except HTTPException as exc: + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常")) + return _bulk_result(results) + + @router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理") def handle_feedback( feedback_id: int, @@ -93,53 +258,7 @@ def approve_feedback( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> FeedbackOut: - fb = db.get(Feedback, feedback_id) - if fb is None: - raise HTTPException(status_code=404, detail="反馈不存在") - _ensure_pending(fb) - - before = fb.status - mutations.review_feedback( - db, - fb, - status="adopted", - reward_coins=payload.reward_coins, - review_note=payload.note, - admin_reply=payload.reply, - reviewed_by_admin_id=admin.id, - commit=False, - ) - wallet_repo.grant_coins( - db, - fb.user_id, - payload.reward_coins, - biz_type="feedback_reward", - ref_id=str(fb.id), - remark="意见反馈被采纳", - ) - write_audit( - db, - admin, - action="feedback.approve", - target_type="feedback", - target_id=feedback_id, - detail={ - "before": before, - "after": "adopted", - "reward_coins": payload.reward_coins, - "note": payload.note, - "reply": payload.reply, - }, - ip=get_client_ip(request), - commit=False, - ) - db.commit() - db.refresh(fb) - out = FeedbackOut.model_validate(fb) - # PRD #10 反馈奖励:采纳发金币后通知用户(站内 + push,必带官方留言)。 - # 业务已 commit,通知失败只 log 不影响审核结果。 - notification_events.notify_feedback_reward(db, fb) - return out + return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request)) @router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈") @@ -150,41 +269,4 @@ def reject_feedback( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> FeedbackOut: - fb = db.get(Feedback, feedback_id) - if fb is None: - raise HTTPException(status_code=404, detail="反馈不存在") - _ensure_pending(fb) - - before = fb.status - mutations.review_feedback( - db, - fb, - status="rejected", - reject_reason=payload.reason, - review_note=payload.note, - admin_reply=payload.reply, - reviewed_by_admin_id=admin.id, - commit=False, - ) - write_audit( - db, - admin, - action="feedback.reject", - target_type="feedback", - target_id=feedback_id, - detail={ - "before": before, - "after": "rejected", - "reason": payload.reason, - "note": payload.note, - "reply": payload.reply, - }, - ip=get_client_ip(request), - commit=False, - ) - db.commit() - db.refresh(fb) - out = FeedbackOut.model_validate(fb) - # PRD #9 官方回复:未采纳也回复了用户(原因/留言用户端可见),通知去反馈历史页查看。 - notification_events.notify_feedback_reply(db, fb) - return out + return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request)) diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py index 9f65545..6f656a7 100644 --- a/app/admin/routers/price_report.py +++ b/app/admin/routers/price_report.py @@ -17,6 +17,10 @@ from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_ro from app.admin.repositories import mutations, queries from app.admin.schemas.common import CursorPage, OkResponse from app.admin.schemas.price_report import ( + PriceReportBulkItemResult, + PriceReportBulkRejectRequest, + PriceReportBulkRequest, + PriceReportBulkResult, PriceReportOut, PriceReportRejectRequest, PriceReportSummary, @@ -34,6 +38,59 @@ router = APIRouter( ) +def _approve_price_report( + db: AdminDb, admin: AdminUser, report_id: int, ip: str, *, bulk: bool = False +) -> PriceReport: + rep = db.get(PriceReport, report_id, with_for_update=True) + 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 + 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="上报更低价审核通过", + ) + detail = {"reward_coins": coins, "user_id": rep.user_id} + if bulk: + detail["bulk"] = True + write_audit( + db, admin, action="price_report.approve", target_type="price_report", target_id=report_id, + detail=detail, ip=ip, commit=False, + ) + db.commit() + notification_events.notify_report_approved(db, rep) + return rep + + +def _reject_price_report( + db: AdminDb, admin: AdminUser, report_id: int, reason: str, ip: str, *, bulk: bool = False +) -> PriceReport: + rep = db.get(PriceReport, report_id, with_for_update=True) + if rep is None: + raise HTTPException(status_code=404, detail="上报记录不存在") + if rep.status != "pending": + raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") + mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False) + detail = {"reason": reason, "user_id": rep.user_id} + if bulk: + detail["bulk"] = True + write_audit( + db, admin, action="price_report.reject", target_type="price_report", target_id=report_id, + detail=detail, ip=ip, commit=False, + ) + db.commit() + return rep + + +def _bulk_result(items: list[PriceReportBulkItemResult]) -> PriceReportBulkResult: + success = sum(1 for item in items if item.ok) + return PriceReportBulkResult( + total=len(items), success=success, failed=len(items) - success, items=items, + ) + + @router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)") def list_price_reports( db: AdminDb, @@ -60,6 +117,50 @@ def price_report_summary(db: AdminDb) -> PriceReportSummary: return PriceReportSummary.model_validate(queries.price_report_summary(db)) +@router.post("/bulk/approve", response_model=PriceReportBulkResult, summary="批量通过上报(发固定金币)") +def bulk_approve_price_reports( + body: PriceReportBulkRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> PriceReportBulkResult: + results: list[PriceReportBulkItemResult] = [] + ip = get_client_ip(request) + for report_id in body.ids: + try: + rep = _approve_price_report(db, admin, report_id, ip, bulk=True) + results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status)) + except HTTPException as exc: + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常")) + return _bulk_result(results) + + +@router.post("/bulk/reject", response_model=PriceReportBulkResult, summary="批量拒绝上报") +def bulk_reject_price_reports( + body: PriceReportBulkRejectRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> PriceReportBulkResult: + results: list[PriceReportBulkItemResult] = [] + ip = get_client_ip(request) + for report_id in body.ids: + try: + rep = _reject_price_report(db, admin, report_id, body.reason, ip, bulk=True) + results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status)) + except HTTPException as exc: + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常")) + return _bulk_result(results) + + @router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)") def approve_price_report( report_id: int, @@ -67,27 +168,7 @@ def approve_price_report( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> OkResponse: - # 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖, - # 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。 - rep = db.get(PriceReport, report_id, with_for_update=True) - 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() - # PRD #11 爆料审核通过:发金币后通知用户(站内 + push)。业务已 commit,通知失败只 log。 - notification_events.notify_report_approved(db, rep) + _approve_price_report(db, admin, report_id, get_client_ip(request)) return OkResponse() @@ -99,16 +180,5 @@ def reject_price_report( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> OkResponse: - rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核) - 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() + _reject_price_report(db, admin, report_id, body.reason, get_client_ip(request)) return OkResponse() diff --git a/app/admin/schemas/feedback.py b/app/admin/schemas/feedback.py index bb588cf..dde6229 100644 --- a/app/admin/schemas/feedback.py +++ b/app/admin/schemas/feedback.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.rewards import FEEDBACK_REWARD_MAX_COINS @@ -55,6 +55,54 @@ class FeedbackRejectRequest(BaseModel): reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见") +class FeedbackBulkRequest(BaseModel): + ids: list[int] = Field(min_length=1, max_length=50, description="待审核反馈 ID 列表") + + @field_validator("ids") + @classmethod + def _ids_must_be_unique(cls, ids: list[int]) -> list[int]: + if len(ids) != len(set(ids)): + raise ValueError("反馈 ID 不能重复") + return ids + + +class FeedbackBulkApproveRequest(FeedbackBulkRequest): + reward_coins: int = Field( + ge=1, + le=FEEDBACK_REWARD_MAX_COINS, + description="每条采纳反馈发放的金币数", + ) + note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)") + reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见") + + +class FeedbackBulkRejectRequest(FeedbackBulkRequest): + reason: str = Field(min_length=1, max_length=256, description="批量未采纳原因,用户端可见") + note: str | None = Field(default=None, max_length=256, description="运营内部审核备注") + reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见") + + @field_validator("reason") + @classmethod + def _reason_not_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError("未采纳原因不能为空") + return value.strip() + + +class FeedbackBulkItemResult(BaseModel): + id: int + ok: bool + status: str | None = None + error: str | None = None + + +class FeedbackBulkResult(BaseModel): + total: int + success: int + failed: int + items: list[FeedbackBulkItemResult] + + class FeedbackSummary(BaseModel): """审核台顶部各状态计数(pending 含历史 new 态)。""" diff --git a/app/admin/schemas/price_report.py b/app/admin/schemas/price_report.py index abb89d9..d67208a 100644 --- a/app/admin/schemas/price_report.py +++ b/app/admin/schemas/price_report.py @@ -56,6 +56,42 @@ class PriceReportRejectRequest(BaseModel): return v.strip() +class PriceReportBulkRequest(BaseModel): + ids: list[int] = Field(min_length=1, max_length=50, description="待审核上报 ID 列表") + + @field_validator("ids") + @classmethod + def _ids_must_be_unique(cls, ids: list[int]) -> list[int]: + if len(ids) != len(set(ids)): + raise ValueError("上报 ID 不能重复") + return ids + + +class PriceReportBulkRejectRequest(PriceReportBulkRequest): + reason: str = Field(min_length=1, max_length=256, description="批量拒绝理由,用户端记录页会看到") + + @field_validator("reason") + @classmethod + def _reason_not_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError("拒绝理由不能为空") + return value.strip() + + +class PriceReportBulkItemResult(BaseModel): + id: int + ok: bool + status: str | None = None + error: str | None = None + + +class PriceReportBulkResult(BaseModel): + total: int + success: int + failed: int + items: list[PriceReportBulkItemResult] + + class PriceReportSummary(BaseModel): """审核台顶部各状态计数。""" diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index ec6d39d..fc6b73c 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -14,6 +14,7 @@ from app.core.security import hash_password from app.db.session import SessionLocal from app.models.admin import AdminAuditLog from app.models.feedback import Feedback +from app.models.price_report import PriceReport from app.models.user import User from app.models.wallet import CoinAccount, CoinTransaction, WithdrawOrder from app.repositories import user as user_repo @@ -82,6 +83,26 @@ def _seed_feedback(phone: str) -> int: db.close() +def _seed_price_report(phone: str) -> int: + uid = _seed_user(phone) + db = SessionLocal() + try: + report = PriceReport( + user_id=uid, + store_name="测试门店", + reported_platform_id="eleme", + reported_platform_name="饿了么", + reported_price_cents=2990, + images=[], + status="pending", + ) + db.add(report) + db.commit() + return report.id + finally: + db.close() + + # ===== 调金币 ===== def test_grant_coins_writes_txn_and_audit(admin_client: TestClient, finance_token: str) -> None: @@ -397,6 +418,100 @@ def test_feedback_review_stores_admin_reply( assert r.json()["admin_reply"] == "已收到,后续跟进" +def test_bulk_approve_feedbacks_returns_per_item_results( + admin_client: TestClient, operator_token: str +) -> None: + first_id = _seed_feedback("13900000031") + second_id = _seed_feedback("13900000032") + r = admin_client.post( + "/admin/api/feedbacks/bulk/approve", + json={"ids": [first_id, second_id, 999999], "reward_coins": 600, "note": "批量采纳"}, + headers=_auth(operator_token), + ) + assert r.status_code == 200, r.text + payload = r.json() + assert payload["total"] == 3 and payload["success"] == 2 and payload["failed"] == 1 + assert payload["items"][-1] == {"id": 999999, "ok": False, "status": None, "error": "反馈不存在"} + db = SessionLocal() + try: + for feedback_id in (first_id, second_id): + feedback = db.get(Feedback, feedback_id) + assert feedback is not None and feedback.status == "adopted" + assert db.get(CoinAccount, feedback.user_id).coin_balance == 600 + log = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "feedback.approve", + AdminAuditLog.target_id == str(feedback_id), + ) + ).scalar_one() + assert log.detail["bulk"] is True + finally: + db.close() + + +def test_bulk_approve_price_reports_returns_per_item_results( + admin_client: TestClient, operator_token: str +) -> None: + first_id = _seed_price_report("13900000041") + second_id = _seed_price_report("13900000042") + r = admin_client.post( + "/admin/api/price-reports/bulk/approve", + json={"ids": [first_id, second_id, 999999]}, + headers=_auth(operator_token), + ) + assert r.status_code == 200, r.text + payload = r.json() + assert payload["total"] == 3 and payload["success"] == 2 and payload["failed"] == 1 + assert payload["items"][-1] == {"id": 999999, "ok": False, "status": None, "error": "上报记录不存在"} + db = SessionLocal() + try: + for report_id in (first_id, second_id): + report = db.get(PriceReport, report_id) + assert report is not None and report.status == "approved" + assert report.reward_coins == 1000 + assert db.get(CoinAccount, report.user_id).coin_balance == 1000 + log = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "price_report.approve", + AdminAuditLog.target_id == str(report_id), + ) + ).scalar_one() + assert log.detail["bulk"] is True + finally: + db.close() + + +def test_bulk_reject_review_requests_apply_shared_reason( + admin_client: TestClient, operator_token: str +) -> None: + feedback_id = _seed_feedback("13900000051") + report_id = _seed_price_report("13900000052") + feedback_response = admin_client.post( + "/admin/api/feedbacks/bulk/reject", + json={"ids": [feedback_id], "reason": "信息不足", "reply": "请补充完整截图"}, + headers=_auth(operator_token), + ) + report_response = admin_client.post( + "/admin/api/price-reports/bulk/reject", + json={"ids": [report_id], "reason": "截图无法核实"}, + headers=_auth(operator_token), + ) + assert feedback_response.status_code == 200, feedback_response.text + assert report_response.status_code == 200, report_response.text + assert feedback_response.json()["success"] == 1 + assert report_response.json()["success"] == 1 + db = SessionLocal() + try: + feedback = db.get(Feedback, feedback_id) + report = db.get(PriceReport, report_id) + assert feedback is not None and feedback.status == "rejected" + assert feedback.reject_reason == "信息不足" and feedback.admin_reply == "请补充完整截图" + assert report is not None and report.status == "rejected" + assert report.reject_reason == "截图无法核实" + finally: + db.close() + + # ===== admin 账号管理(super_admin) ===== def test_create_and_update_admin(admin_client: TestClient, super_token: str) -> None: