From f868414966e0040230c80bd8c92e5f1bfeb1aca4 Mon Sep 17 00:00:00 2001 From: chenshuobo Date: Mon, 22 Jun 2026 23:18:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(feedback):=20=E8=A1=A5=E9=BD=90=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E5=8E=86=E5=8F=B2=E5=AE=A1=E6=A0=B8=E5=8F=91=E5=A5=96?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增反馈审核字段和迁移,支持 pending/adopted/rejected、未采纳原因和采纳金币 - 增加用户端反馈历史 records 接口和 admin 采纳/拒绝接口 - 采纳时同事务写状态、金币流水和审计日志,拦截重复审核 - 验证:pytest tests/test_feedback.py tests/test_admin_write.py tests/test_admin_read.py --------- Co-authored-by: lowmaster-chen <1119780489@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/66 Co-authored-by: chenshuobo Co-committed-by: chenshuobo --- alembic/versions/feedback_review_fields.py | 78 +++++++++++++++ app/admin/repositories/mutations.py | 29 ++++++ app/admin/repositories/stats.py | 2 +- app/admin/routers/feedback.py | 108 +++++++++++++++++++-- app/admin/schemas/feedback.py | 23 ++++- app/api/v1/feedback.py | 52 +++++++++- app/core/rewards.py | 4 + app/models/feedback.py | 16 ++- app/repositories/feedback.py | 18 +++- app/schemas/feedback.py | 24 ++++- tests/test_admin_read.py | 8 +- tests/test_admin_write.py | 87 +++++++++++++++-- tests/test_feedback.py | 77 +++++++++++++++ 13 files changed, 499 insertions(+), 27 deletions(-) create mode 100644 alembic/versions/feedback_review_fields.py create mode 100644 tests/test_feedback.py diff --git a/alembic/versions/feedback_review_fields.py b/alembic/versions/feedback_review_fields.py new file mode 100644 index 0000000..749a884 --- /dev/null +++ b/alembic/versions/feedback_review_fields.py @@ -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") diff --git a/app/admin/repositories/mutations.py b/app/admin/repositories/mutations.py index 5f3ed6e..7470c39 100644 --- a/app/admin/repositories/mutations.py +++ b/app/admin/repositories/mutations.py @@ -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, diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 199ff31..26b774d 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -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)"}, } diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 249378d..0af2fca 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -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) diff --git a/app/admin/schemas/feedback.py b/app/admin/schemas/feedback.py index c32889a..bec9107 100644 --- a/app/admin/schemas/feedback.py +++ b/app/admin/schemas/feedback.py @@ -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="运营内部审核备注") diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py index 79f1a61..7f8b562 100644 --- a/app/api/v1/feedback.py +++ b/app/api/v1/feedback.py @@ -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) diff --git a/app/core/rewards.py b/app/core/rewards.py index e9eac9a..6acf242 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -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)。 diff --git a/app/models/feedback.py b/app/models/feedback.py index caef033..b8d7ad6 100644 --- a/app/models/feedback.py +++ b/app/models/feedback.py @@ -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 diff --git a/app/repositories/feedback.py b/app/repositories/feedback.py index fc20b1f..6c5f8a5 100644 --- a/app/repositories/feedback.py +++ b/app/repositories/feedback.py @@ -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()) diff --git a/app/schemas/feedback.py b/app/schemas/feedback.py index 4938a74..d903cca 100644 --- a/app/schemas/feedback.py +++ b/app/schemas/feedback.py @@ -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 + diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 90005ad..22d5efc 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -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( diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index bd8cd8e..95f57e9 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -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() diff --git a/tests/test_feedback.py b/tests/test_feedback.py new file mode 100644 index 0000000..8ec524f --- /dev/null +++ b/tests/test_feedback.py @@ -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"] == "暂未提供可复现信息"