4512b6ecac
用户反馈后台:区分反馈类型(比价反馈/普通反馈)+ 审核可给用户留言;提现后台:按提现类型筛选。 - feedback 表加 source(profile/comparison)/scene/admin_reply + 迁移;提交接口 /api/v1/feedback 接收 source/scene,来源判定显式 source 优先、否则据 scene 有无派生(有=comparison); /records 带回 scene + admin_reply。 - admin 反馈:列表加「反馈类型」筛选;采纳/拒绝支持存 admin_reply(给用户的回复,用户端可见); FeedbackOut 带出 source/scene/admin_reply。 - admin 提现:WithdrawOut 暴露 source,列表加「提现类型」筛选(coin_cash=福利页提现 / invite_cash=邀请提现)。 - 补 4 项测试:来源派生、回复存取、反馈按类型筛选、提现按类型筛选。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #105 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
148 lines
5.8 KiB
Python
148 lines
5.8 KiB
Python
"""帮助与反馈 endpoint。
|
|
|
|
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
|
|
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 ≤6 张)
|
|
GET /records 我的反馈历史(pending/adopted/rejected)
|
|
|
|
截图复用 [app.core.media] 落盘到 /media/feedback/。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
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,
|
|
FeedbackRecordCountsOut,
|
|
FeedbackRecordOut,
|
|
FeedbackRecordsOut,
|
|
)
|
|
|
|
logger = logging.getLogger("shagua.feedback")
|
|
|
|
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
|
|
|
_MAX_IMAGES = 6
|
|
_CONTENT_MAX = 200
|
|
_CONTACT_MAX = 128
|
|
_SCENE_MAX = 32
|
|
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
|
|
_VALID_SOURCES = {"profile", "comparison"}
|
|
|
|
|
|
def _resolve_source(source: str, scene: str) -> str:
|
|
"""反馈来源:客户端显式传的 source 优先(profile / comparison);未传或非法时,
|
|
按 scene 有无派生——比价结果页反馈才带 scene,故 scene 非空即视作 comparison。"""
|
|
src = source.strip().lower()
|
|
if src in _VALID_SOURCES:
|
|
return src
|
|
return "comparison" if scene.strip() else "profile"
|
|
|
|
|
|
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,
|
|
scene=getattr(fb, "scene", None),
|
|
images=fb.images or [],
|
|
status=_app_status(fb.status),
|
|
reject_reason=getattr(fb, "reject_reason", None),
|
|
reward_coins=getattr(fb, "reward_coins", None),
|
|
admin_reply=getattr(fb, "admin_reply", None),
|
|
created_at=fb.created_at,
|
|
)
|
|
|
|
|
|
@router.post("", response_model=FeedbackOut, summary="提交反馈")
|
|
async def submit_feedback(
|
|
user: CurrentUser,
|
|
db: DbSession,
|
|
content: str = Form(...),
|
|
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
|
|
contact: str = Form(default=""),
|
|
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)。新端显式传;旧端不带 →
|
|
# 空串 → 按 scene 有无派生。admin 据此展示/筛选「反馈类型」。
|
|
source: str = Form(default=""),
|
|
# 比价结果页反馈的「问题场景」(找错商品/优惠不对…);普通反馈不带 → 空串 → 存 NULL。
|
|
scene: str = Form(default=""),
|
|
# 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。
|
|
app_version: str = Form(default=""),
|
|
device_model: str = Form(default=""),
|
|
rom_name: str = Form(default=""),
|
|
android_version: str = Form(default=""),
|
|
images: list[UploadFile] = File(default=[]),
|
|
) -> FeedbackOut:
|
|
content = content.strip()
|
|
contact = contact.strip()
|
|
if not content:
|
|
raise HTTPException(status_code=400, detail="反馈内容不能为空")
|
|
if len(content) > _CONTENT_MAX:
|
|
raise HTTPException(status_code=400, detail="反馈内容过长")
|
|
if len(contact) > _CONTACT_MAX:
|
|
raise HTTPException(status_code=400, detail="联系方式过长")
|
|
scene = scene.strip()[:_SCENE_MAX]
|
|
resolved_source = _resolve_source(source, scene)
|
|
|
|
files = [f for f in (images or []) if f is not None and f.filename]
|
|
if len(files) > _MAX_IMAGES:
|
|
raise HTTPException(status_code=400, detail=f"最多上传 {_MAX_IMAGES} 张图片")
|
|
|
|
urls: list[str] = []
|
|
for f in files:
|
|
data = await f.read()
|
|
try:
|
|
urls.append(media.save_feedback_image(user.id, data))
|
|
except media.MediaError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
|
|
fb = feedback_repo.create_feedback(
|
|
db, user_id=user.id, content=content, contact=contact, images=urls,
|
|
source=resolved_source, scene=scene or None,
|
|
app_version=app_version.strip(), device_model=device_model.strip(),
|
|
rom_name=rom_name.strip(), android_version=android_version.strip(),
|
|
)
|
|
logger.info(
|
|
"feedback id=%d user_id=%d source=%s images=%d", fb.id, user.id, resolved_source, len(urls)
|
|
)
|
|
return FeedbackOut.model_validate(fb)
|
|
|
|
|
|
@router.get("/config", response_model=FeedbackQrConfigOut, summary="反馈页二维码卡配置")
|
|
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)
|