feat(feedback): 补齐反馈历史审核发奖闭环 #66
@@ -0,0 +1,78 @@
|
||||
"""feedback review fields
|
||||
|
||||
Revision ID: feedback_review_fields
|
||||
Revises: ceb286289426
|
||||
Create Date: 2026-06-22 20:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "feedback_review_fields"
|
||||
down_revision: str | Sequence[str] | None = "ceb286289426"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("feedback", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("reject_reason", sa.String(length=256), nullable=True))
|
||||
batch_op.add_column(sa.Column("reward_coins", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("review_note", sa.String(length=256), nullable=True))
|
||||
batch_op.add_column(sa.Column("reviewed_by_admin_id", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch_op.create_index(batch_op.f("ix_feedback_status"), ["status"], unique=False)
|
||||
batch_op.create_foreign_key(
|
||||
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
|
||||
"admin_user",
|
||||
["reviewed_by_admin_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
feedback = sa.table(
|
||||
"feedback",
|
||||
sa.column("status", sa.String(length=16)),
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "new")
|
||||
.values(status="pending")
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "handled")
|
||||
.values(status="adopted")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
feedback = sa.table(
|
||||
"feedback",
|
||||
sa.column("status", sa.String(length=16)),
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "pending")
|
||||
.values(status="new")
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "adopted")
|
||||
.values(status="handled")
|
||||
)
|
||||
|
||||
with op.batch_alter_table("feedback", schema=None) as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
|
||||
type_="foreignkey",
|
||||
)
|
||||
batch_op.drop_index(batch_op.f("ix_feedback_status"))
|
||||
batch_op.drop_column("reviewed_at")
|
||||
batch_op.drop_column("reviewed_by_admin_id")
|
||||
batch_op.drop_column("review_note")
|
||||
batch_op.drop_column("reward_coins")
|
||||
batch_op.drop_column("reject_reason")
|
||||
@@ -56,6 +56,35 @@ def update_feedback_status(
|
||||
return feedback
|
||||
|
||||
|
||||
def review_feedback(
|
||||
db: Session,
|
||||
feedback: Feedback,
|
||||
*,
|
||||
status: str,
|
||||
reviewed_by_admin_id: int,
|
||||
reward_coins: int | None = None,
|
||||
reject_reason: str | None = None,
|
||||
review_note: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> Feedback:
|
||||
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
|
||||
|
||||
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
|
||||
"""
|
||||
feedback.status = status
|
||||
feedback.reward_coins = reward_coins
|
||||
feedback.reject_reason = reject_reason
|
||||
feedback.review_note = review_note
|
||||
feedback.reviewed_by_admin_id = reviewed_by_admin_id
|
||||
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(feedback)
|
||||
else:
|
||||
db.flush()
|
||||
return feedback
|
||||
|
||||
|
||||
def review_price_report(
|
||||
db: Session,
|
||||
report: PriceReport,
|
||||
|
||||
@@ -119,7 +119,7 @@ def dashboard_overview(db: Session) -> dict:
|
||||
"success": comparison_success,
|
||||
"success_rate": success_rate,
|
||||
},
|
||||
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
|
||||
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
|
||||
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
|
||||
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
|
||||
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
@@ -10,9 +10,10 @@ 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.feedback import FeedbackOut
|
||||
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.feedback import Feedback
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/feedbacks",
|
||||
@@ -21,6 +22,11 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def _ensure_pending(fb: Feedback) -> None:
|
||||
if fb.status not in {"pending", "new"}:
|
||||
raise HTTPException(status_code=400, detail="反馈已审核")
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
|
||||
def list_feedbacks(
|
||||
db: AdminDb,
|
||||
@@ -56,18 +62,104 @@ def list_feedbacks(
|
||||
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
||||
def handle_feedback(
|
||||
feedback_id: int,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
_admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
fb = db.get(Feedback, feedback_id)
|
||||
if fb is None:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
raise HTTPException(status_code=400, detail="请使用采纳或拒绝接口审核反馈")
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/approve", response_model=FeedbackOut, summary="采纳反馈并发金币")
|
||||
def approve_feedback(
|
||||
feedback_id: int,
|
||||
payload: FeedbackApproveRequest,
|
||||
request: Request,
|
||||
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.update_feedback_status(db, fb, status="handled", commit=False)
|
||||
mutations.review_feedback(
|
||||
db,
|
||||
fb,
|
||||
status="adopted",
|
||||
reward_coins=payload.reward_coins,
|
||||
review_note=payload.note,
|
||||
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.handle", target_type="feedback", target_id=feedback_id,
|
||||
detail={"before": before, "after": "handled"}, ip=get_client_ip(request), commit=False,
|
||||
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,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
db.refresh(fb)
|
||||
return FeedbackOut.model_validate(fb)
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
|
||||
def reject_feedback(
|
||||
feedback_id: int,
|
||||
payload: FeedbackRejectRequest,
|
||||
request: Request,
|
||||
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,
|
||||
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,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(fb)
|
||||
return FeedbackOut.model_validate(fb)
|
||||
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
|
||||
|
||||
|
||||
class FeedbackOut(BaseModel):
|
||||
@@ -15,4 +17,23 @@ class FeedbackOut(BaseModel):
|
||||
contact: str
|
||||
images: list[str] | None = None
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
review_note: str | None = None
|
||||
reviewed_by_admin_id: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FeedbackApproveRequest(BaseModel):
|
||||
reward_coins: int = Field(
|
||||
ge=1,
|
||||
le=FEEDBACK_REWARD_MAX_COINS,
|
||||
description="采纳后发放金币数",
|
||||
)
|
||||
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注")
|
||||
|
||||
|
||||
class FeedbackRejectRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
|
||||
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
|
||||
|
||||
+50
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
|
||||
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 ≤6 张)
|
||||
GET /records 我的反馈历史(pending/adopted/rejected)
|
||||
|
||||
截图复用 [app.core.media] 落盘到 /media/feedback/。
|
||||
"""
|
||||
@@ -9,13 +10,19 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import media
|
||||
from app.repositories import feedback as feedback_repo
|
||||
from app.repositories import feedback_qr as feedback_qr_repo
|
||||
from app.schemas.feedback import FeedbackOut, FeedbackQrConfigOut
|
||||
from app.schemas.feedback import (
|
||||
FeedbackOut,
|
||||
FeedbackQrConfigOut,
|
||||
FeedbackRecordCountsOut,
|
||||
FeedbackRecordOut,
|
||||
FeedbackRecordsOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.feedback")
|
||||
|
||||
@@ -24,6 +31,27 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
||||
_MAX_IMAGES = 6
|
||||
_CONTENT_MAX = 200
|
||||
_CONTACT_MAX = 128
|
||||
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
|
||||
|
||||
|
||||
def _app_status(db_status: str) -> str:
|
||||
return {
|
||||
"new": "pending",
|
||||
"handled": "adopted",
|
||||
"approved": "adopted",
|
||||
}.get(db_status, db_status)
|
||||
|
||||
|
||||
def _record_out(fb) -> FeedbackRecordOut:
|
||||
return FeedbackRecordOut(
|
||||
id=fb.id,
|
||||
content=fb.content,
|
||||
images=fb.images or [],
|
||||
status=_app_status(fb.status),
|
||||
reject_reason=getattr(fb, "reject_reason", None),
|
||||
reward_coins=getattr(fb, "reward_coins", None),
|
||||
created_at=fb.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=FeedbackOut, summary="提交反馈")
|
||||
@@ -67,3 +95,23 @@ async def submit_feedback(
|
||||
def feedback_config(user: CurrentUser, db: DbSession) -> FeedbackQrConfigOut:
|
||||
"""运营后台配的反馈页「加群二维码」卡(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取。"""
|
||||
return FeedbackQrConfigOut(**feedback_qr_repo.get_config(db))
|
||||
|
||||
|
||||
@router.get("/records", response_model=FeedbackRecordsOut, summary="我的反馈历史")
|
||||
def feedback_records(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
status: str | None = Query(default=None),
|
||||
) -> FeedbackRecordsOut:
|
||||
if status is not None and status not in _VALID_RECORD_STATUS:
|
||||
raise HTTPException(status_code=400, detail="invalid status")
|
||||
|
||||
all_records = [_record_out(fb) for fb in feedback_repo.list_feedback(db, user_id=user.id)]
|
||||
counts = FeedbackRecordCountsOut(
|
||||
all=len(all_records),
|
||||
pending=sum(1 for r in all_records if r.status == "pending"),
|
||||
adopted=sum(1 for r in all_records if r.status == "adopted"),
|
||||
rejected=sum(1 for r in all_records if r.status == "rejected"),
|
||||
)
|
||||
records = [r for r in all_records if r.status == status] if status else all_records
|
||||
return FeedbackRecordsOut(records=records, counts=counts)
|
||||
|
||||
@@ -108,6 +108,10 @@ def record_milestone_reward(milestone: int) -> int:
|
||||
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
|
||||
PRICE_REPORT_REWARD_COINS: int = 1000
|
||||
|
||||
# ===== 意见反馈采纳奖励(人工审核按质量发放)=====
|
||||
# 后台审核反馈时允许发放的单条金币上限。只做后端硬保护,具体档位由 admin-web 呈现。
|
||||
FEEDBACK_REWARD_MAX_COINS: int = 10000
|
||||
|
||||
|
||||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||||
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
||||
|
||||
+13
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
每条 = 用户一次提交。content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
|
||||
新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
|
||||
(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。
|
||||
(/media/feedback/...,JSON 存)。status: pending(审核中)/adopted(已采纳)/rejected(未采纳)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,8 +25,18 @@ class Feedback(Base):
|
||||
contact: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
|
||||
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
# new(待处理) / handled(已处理)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
|
||||
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
|
||||
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)
|
||||
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
|
||||
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
reviewed_by_admin_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("admin_user.id"), 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
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""feedback 表写入。"""
|
||||
"""feedback 表读写。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.feedback import Feedback
|
||||
|
||||
|
||||
@@ -19,9 +23,19 @@ def create_feedback(
|
||||
content=content,
|
||||
contact=contact,
|
||||
images=images or None,
|
||||
status="new",
|
||||
status="pending",
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
db.add(fb)
|
||||
db.commit()
|
||||
db.refresh(fb)
|
||||
return fb
|
||||
|
||||
|
||||
def list_feedback(db: Session, *, user_id: int) -> list[Feedback]:
|
||||
stmt = (
|
||||
select(Feedback)
|
||||
.where(Feedback.user_id == user_id)
|
||||
.order_by(Feedback.created_at.desc(), Feedback.id.desc())
|
||||
)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
+23
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class FeedbackOut(BaseModel):
|
||||
@@ -26,3 +26,25 @@ class FeedbackQrConfigOut(BaseModel):
|
||||
group_name: str
|
||||
subtitle: str
|
||||
|
||||
|
||||
class FeedbackRecordOut(BaseModel):
|
||||
id: int
|
||||
content: str
|
||||
images: list[str] = Field(default_factory=list)
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FeedbackRecordCountsOut(BaseModel):
|
||||
all: int = 0
|
||||
pending: int = 0
|
||||
adopted: int = 0
|
||||
rejected: int = 0
|
||||
|
||||
|
||||
class FeedbackRecordsOut(BaseModel):
|
||||
records: list[FeedbackRecordOut]
|
||||
counts: FeedbackRecordCountsOut
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def _seed_user_with_data(phone: str) -> int:
|
||||
db.add(WithdrawOrder(
|
||||
user_id=uid, out_bill_no=f"test{uid}billno0001", amount_cents=100, status="success"
|
||||
))
|
||||
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="new"))
|
||||
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="pending"))
|
||||
db.commit()
|
||||
return uid
|
||||
finally:
|
||||
@@ -181,9 +181,11 @@ def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -
|
||||
|
||||
def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
|
||||
_seed_user_with_data("13800000005")
|
||||
r = admin_client.get("/admin/api/feedbacks", params={"status": "new"}, headers=_auth(admin_token))
|
||||
r = admin_client.get(
|
||||
"/admin/api/feedbacks", params={"status": "pending"}, headers=_auth(admin_token)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert all(f["status"] == "new" for f in r.json()["items"])
|
||||
assert all(f["status"] == "pending" for f in r.json()["items"])
|
||||
|
||||
|
||||
def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
|
||||
|
||||
@@ -74,7 +74,7 @@ def _seed_feedback(phone: str) -> int:
|
||||
uid = _seed_user(phone)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
fb = Feedback(user_id=uid, content="测试", contact="wx", status="new")
|
||||
fb = Feedback(user_id=uid, content="测试", contact="wx", status="pending")
|
||||
db.add(fb)
|
||||
db.commit()
|
||||
return fb.id
|
||||
@@ -280,15 +280,90 @@ def test_super_admin_can_grant_coins(admin_client: TestClient, super_token: str)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ===== 反馈处理 =====
|
||||
# ===== 反馈审核 =====
|
||||
|
||||
def test_handle_feedback(admin_client: TestClient, operator_token: str) -> None:
|
||||
def test_approve_feedback_grants_coins_and_audit(
|
||||
admin_client: TestClient, operator_token: str
|
||||
) -> None:
|
||||
fid = _seed_feedback("13900000006")
|
||||
r = admin_client.post(f"/admin/api/feedbacks/{fid}/handle", headers=_auth(operator_token))
|
||||
assert r.status_code == 200
|
||||
r = admin_client.post(
|
||||
f"/admin/api/feedbacks/{fid}/approve",
|
||||
json={"reward_coins": 800, "note": "真实详细,已采纳"},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "adopted"
|
||||
assert r.json()["reward_coins"] == 800
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.get(Feedback, fid).status == "handled"
|
||||
fb = db.get(Feedback, fid)
|
||||
assert fb is not None
|
||||
assert fb.status == "adopted"
|
||||
assert fb.reward_coins == 800
|
||||
assert fb.review_note == "真实详细,已采纳"
|
||||
assert fb.reviewed_by_admin_id is not None
|
||||
assert fb.reviewed_at is not None
|
||||
assert db.get(CoinAccount, fb.user_id).coin_balance == 800
|
||||
txns = db.execute(
|
||||
select(CoinTransaction).where(
|
||||
CoinTransaction.user_id == fb.user_id,
|
||||
CoinTransaction.biz_type == "feedback_reward",
|
||||
CoinTransaction.ref_id == str(fid),
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(txns) == 1 and txns[0].amount == 800
|
||||
logs = db.execute(
|
||||
select(AdminAuditLog).where(
|
||||
AdminAuditLog.action == "feedback.approve",
|
||||
AdminAuditLog.target_id == str(fid),
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(logs) == 1 and logs[0].detail["reward_coins"] == 800
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = admin_client.post(
|
||||
f"/admin/api/feedbacks/{fid}/approve",
|
||||
json={"reward_coins": 100},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_reject_feedback_records_reason_and_no_coin(
|
||||
admin_client: TestClient, operator_token: str
|
||||
) -> None:
|
||||
fid = _seed_feedback("13900000013")
|
||||
r = admin_client.post(
|
||||
f"/admin/api/feedbacks/{fid}/reject",
|
||||
json={"reason": "暂未提供可复现信息", "note": "信息不足"},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "rejected"
|
||||
assert r.json()["reject_reason"] == "暂未提供可复现信息"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
fb = db.get(Feedback, fid)
|
||||
assert fb is not None
|
||||
assert fb.status == "rejected"
|
||||
assert fb.reject_reason == "暂未提供可复现信息"
|
||||
assert fb.review_note == "信息不足"
|
||||
txns = db.execute(
|
||||
select(CoinTransaction).where(
|
||||
CoinTransaction.user_id == fb.user_id,
|
||||
CoinTransaction.biz_type == "feedback_reward",
|
||||
CoinTransaction.ref_id == str(fid),
|
||||
)
|
||||
).scalars().all()
|
||||
assert txns == []
|
||||
logs = db.execute(
|
||||
select(AdminAuditLog).where(
|
||||
AdminAuditLog.action == "feedback.reject",
|
||||
AdminAuditLog.target_id == str(fid),
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(logs) == 1 and logs[0].detail["reason"] == "暂未提供可复现信息"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""用户反馈接口测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.feedback import Feedback
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def test_feedback_records_empty_returns_200(client) -> None:
|
||||
token = _login(client, "13622000001")
|
||||
|
||||
r = client.get("/api/v1/feedback/records", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {
|
||||
"records": [],
|
||||
"counts": {"all": 0, "pending": 0, "adopted": 0, "rejected": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_feedback_config_returns_default(client) -> None:
|
||||
token = _login(client, "13622000002")
|
||||
|
||||
r = client.get("/api/v1/feedback/config", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["enabled"] is True
|
||||
assert body["group_name"] == "傻瓜比价官方群"
|
||||
|
||||
|
||||
def test_feedback_records_maps_existing_statuses(client) -> None:
|
||||
phone = "13622000003"
|
||||
token = _login(client, phone)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = user_repo.get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
db.add(Feedback(user_id=user.id, content="待处理反馈", contact="", status="pending"))
|
||||
db.add(
|
||||
Feedback(
|
||||
user_id=user.id,
|
||||
content="已采纳反馈",
|
||||
contact="",
|
||||
status="adopted",
|
||||
reward_coins=800,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
Feedback(
|
||||
user_id=user.id,
|
||||
content="未采纳反馈",
|
||||
contact="",
|
||||
status="rejected",
|
||||
reject_reason="暂未提供可复现信息",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = client.get("/api/v1/feedback/records", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["counts"] == {"all": 3, "pending": 1, "adopted": 1, "rejected": 1}
|
||||
by_status = {item["status"]: item for item in body["records"]}
|
||||
assert by_status["adopted"]["reward_coins"] == 800
|
||||
assert by_status["rejected"]["reject_reason"] == "暂未提供可复现信息"
|
||||
Reference in New Issue
Block a user