Compare commits

...

1 Commits

Author SHA1 Message Date
unknown 9f51d51658 feat(admin): add bulk reward review APIs 2026-07-23 18:11:52 +08:00
5 changed files with 470 additions and 119 deletions
+167 -85
View File
@@ -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))
+103 -33
View File
@@ -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()
+49 -1
View File
@@ -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 态)。"""
+36
View File
@@ -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):
"""审核台顶部各状态计数。"""
+115
View File
@@ -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: