"""帮助与反馈 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)