"""帮助与反馈 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 _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="提交反馈") async def submit_feedback( user: CurrentUser, db: DbSession, content: str = Form(...), # 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。 contact: 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="联系方式过长") 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, ) logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, 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)