Jelajahi Sumber

feat(backend): add public feedback, suggestion, and config APIs

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 bulan lalu
induk
melakukan
b1568f062e

+ 49 - 1
backend/app/api/public.py

@@ -1,4 +1,4 @@
-"""公开 API:附件上传等(无需登录)。"""
+"""公开 API:附件上传、反馈/建言提交、热线/二维码读取(无需登录)。"""
 from typing import Annotated, Optional
 
 from fastapi import APIRouter, Depends, File, Form, UploadFile
@@ -9,6 +9,8 @@ from app.core.database import get_db
 from app.core.response import AppHTTPError, success_response
 from app.core.storage import StorageError
 from app.crud import attachment as attachment_crud
+from app.schemas.business import FeedbackCreateBody, SuggestionCreateBody
+from app.services import config_service, feedback_service, suggestion_service
 
 router = APIRouter(prefix="/public", tags=["public"])
 
@@ -41,3 +43,49 @@ async def upload_attachment(
             "size": row.size,
         }
     )
+
+
+@router.get("/hotline")
+def get_hotline(db: Annotated[Session, Depends(get_db)]):
+    return success_response(config_service.get_hotline_payload(db))
+
+
+@router.get("/qrcode")
+def get_qrcode(db: Annotated[Session, Depends(get_db)]):
+    return success_response(config_service.get_qrcode_payload(db))
+
+
+@router.post("/feedback")
+def submit_feedback(
+    body: FeedbackCreateBody,
+    db: Annotated[Session, Depends(get_db)],
+):
+    data = feedback_service.submit_feedback(
+        db,
+        fb_type=body.fb_type,
+        content=body.content,
+        target_name=body.target_name,
+        target_dept=body.target_dept,
+        target_post=body.target_post,
+        reporter_name=body.reporter_name,
+        reporter_phone=body.reporter_phone,
+        attachment_urls=body.attachment_urls,
+    )
+    return success_response(data)
+
+
+@router.post("/suggestion")
+def submit_suggestion(
+    body: SuggestionCreateBody,
+    db: Annotated[Session, Depends(get_db)],
+):
+    data = suggestion_service.submit_suggestion(
+        db,
+        content=body.content,
+        contact_name=body.contact_name,
+        contact_unit=body.contact_unit,
+        contact_post=body.contact_post,
+        contact_phone=body.contact_phone,
+        attachment_urls=body.attachment_urls,
+    )
+    return success_response(data)

+ 24 - 0
backend/app/crud/config.py

@@ -0,0 +1,24 @@
+"""热线 / 二维码配置 CRUD"""
+from typing import Optional
+
+from sqlalchemy.orm import Session
+
+from app.models.business import HotlineConfig, QrcodeConfig
+
+
+def get_hotline(db: Session) -> Optional[HotlineConfig]:
+    return (
+        db.query(HotlineConfig)
+        .filter(HotlineConfig.del_flag == "0")
+        .order_by(HotlineConfig.create_time.asc())
+        .first()
+    )
+
+
+def get_qrcode(db: Session) -> Optional[QrcodeConfig]:
+    return (
+        db.query(QrcodeConfig)
+        .filter(QrcodeConfig.del_flag == "0")
+        .order_by(QrcodeConfig.create_time.asc())
+        .first()
+    )

+ 59 - 0
backend/app/crud/feedback.py

@@ -0,0 +1,59 @@
+"""反馈 CRUD"""
+from datetime import datetime
+from typing import List, Optional
+
+from sqlalchemy.orm import Session
+
+from app.models.business import Feedback
+
+
+def next_biz_no(db: Session, prefix: str = "FK") -> str:
+    """生成业务编号:前缀 + YYYYMMDD + 3 位日序号。"""
+    date_part = datetime.now().strftime("%Y%m%d")
+    head = f"{prefix}{date_part}"
+    last = (
+        db.query(Feedback)
+        .filter(Feedback.biz_no.like(f"{head}%"))
+        .order_by(Feedback.biz_no.desc())
+        .first()
+    )
+    seq = 1
+    if last and last.biz_no and len(last.biz_no) >= 3:
+        try:
+            seq = int(last.biz_no[-3:]) + 1
+        except ValueError:
+            seq = 1
+    return f"{head}{seq:03d}"
+
+
+def create_feedback(
+    db: Session,
+    *,
+    biz_no: str,
+    fb_type: str,
+    content: str,
+    target_name: Optional[str] = None,
+    target_dept: Optional[str] = None,
+    target_post: Optional[str] = None,
+    reporter_name: Optional[str] = None,
+    reporter_phone: Optional[str] = None,
+    attachments: Optional[List[str]] = None,
+    status: str = "待处理",
+) -> Feedback:
+    row = Feedback(
+        biz_no=biz_no,
+        fb_type=fb_type,
+        content=content,
+        target_name=target_name,
+        target_dept=target_dept,
+        target_post=target_post,
+        reporter_name=reporter_name,
+        reporter_phone=reporter_phone,
+        attachments=list(attachments or []),
+        status=status,
+        del_flag="0",
+    )
+    db.add(row)
+    db.commit()
+    db.refresh(row)
+    return row

+ 55 - 0
backend/app/crud/suggestion.py

@@ -0,0 +1,55 @@
+"""建言 CRUD"""
+from datetime import datetime
+from typing import List, Optional
+
+from sqlalchemy.orm import Session
+
+from app.models.business import Suggestion
+
+
+def next_biz_no(db: Session, prefix: str = "JY") -> str:
+    """生成业务编号:前缀 + YYYYMMDD + 3 位日序号。"""
+    date_part = datetime.now().strftime("%Y%m%d")
+    head = f"{prefix}{date_part}"
+    last = (
+        db.query(Suggestion)
+        .filter(Suggestion.biz_no.like(f"{head}%"))
+        .order_by(Suggestion.biz_no.desc())
+        .first()
+    )
+    seq = 1
+    if last and last.biz_no and len(last.biz_no) >= 3:
+        try:
+            seq = int(last.biz_no[-3:]) + 1
+        except ValueError:
+            seq = 1
+    return f"{head}{seq:03d}"
+
+
+def create_suggestion(
+    db: Session,
+    *,
+    biz_no: str,
+    content: str,
+    contact_name: Optional[str] = None,
+    contact_unit: Optional[str] = None,
+    contact_post: Optional[str] = None,
+    contact_phone: Optional[str] = None,
+    attachments: Optional[List[str]] = None,
+    status: str = "待查阅",
+) -> Suggestion:
+    row = Suggestion(
+        biz_no=biz_no,
+        content=content,
+        contact_name=contact_name,
+        contact_unit=contact_unit,
+        contact_post=contact_post,
+        contact_phone=contact_phone,
+        attachments=list(attachments or []),
+        status=status,
+        del_flag="0",
+    )
+    db.add(row)
+    db.commit()
+    db.refresh(row)
+    return row

+ 20 - 0
backend/app/main.py

@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
 from pathlib import Path
 
 from fastapi import FastAPI, Request
+from fastapi.exceptions import RequestValidationError
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import JSONResponse
 from fastapi.staticfiles import StaticFiles
@@ -13,6 +14,7 @@ from app.core.database import SessionLocal, init_db
 from app.core.response import AppHTTPError, error_response, success_response
 from app.services.seed import seed_if_empty
 
+
 settings = get_settings()
 
 
@@ -57,6 +59,24 @@ async def app_http_error_handler(_request: Request, exc: AppHTTPError):
     )
 
 
+@app.exception_handler(RequestValidationError)
+async def request_validation_error_handler(
+    _request: Request, exc: RequestValidationError
+):
+    # 全局约定:校验失败统一 HTTP 400 + 信封,避免 FastAPI 默认 422/detail
+    msg = "请求参数校验失败"
+    errors = exc.errors()
+    if errors:
+        first = errors[0]
+        loc = ".".join(str(x) for x in first.get("loc", ()) if x != "body")
+        detail = first.get("msg") or msg
+        msg = f"{loc}: {detail}" if loc else str(detail)
+    return JSONResponse(
+        status_code=400,
+        content=error_response(400, msg),
+    )
+
+
 @app.get(f"{settings.API_V1_PREFIX}/health")
 def health_check():
     return success_response({"status": "ok"})

+ 36 - 0
backend/app/schemas/business.py

@@ -0,0 +1,36 @@
+"""业务域 schemas(对外 camelCase)"""
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict
+
+
+def _to_camel(name: str) -> str:
+    parts = name.split("_")
+    return parts[0] + "".join(p.capitalize() for p in parts[1:])
+
+
+class CamelModel(BaseModel):
+    model_config = ConfigDict(
+        alias_generator=_to_camel,
+        populate_by_name=True,
+    )
+
+
+class FeedbackCreateBody(CamelModel):
+    fb_type: str
+    content: str
+    target_name: Optional[str] = None
+    target_dept: Optional[str] = None
+    target_post: Optional[str] = None
+    reporter_name: Optional[str] = None
+    reporter_phone: Optional[str] = None
+    attachment_urls: Optional[List[str]] = None
+
+
+class SuggestionCreateBody(CamelModel):
+    content: str
+    contact_name: Optional[str] = None
+    contact_unit: Optional[str] = None
+    contact_post: Optional[str] = None
+    contact_phone: Optional[str] = None
+    attachment_urls: Optional[List[str]] = None

+ 32 - 0
backend/app/services/config_service.py

@@ -0,0 +1,32 @@
+"""热线 / 二维码公开读取"""
+from typing import Any, Dict
+
+from sqlalchemy.orm import Session
+
+from app.core.response import AppHTTPError
+from app.crud import config as config_crud
+
+
+def get_hotline_payload(db: Session) -> Dict[str, Any]:
+    row = config_crud.get_hotline(db)
+    if not row:
+        raise AppHTTPError(404, "热线配置不存在")
+    return {
+        "id": row.id,
+        "phone": row.phone or "",
+        "email": row.email or "",
+        "address": row.address or "",
+        "workTime": row.work_time or "",
+    }
+
+
+def get_qrcode_payload(db: Session) -> Dict[str, Any]:
+    row = config_crud.get_qrcode(db)
+    if not row:
+        raise AppHTTPError(404, "二维码配置不存在")
+    return {
+        "id": row.id,
+        "landUrl": row.land_url or "",
+        "title": row.title or "",
+        "imageUrl": row.image_url or "",
+    }

+ 41 - 0
backend/app/services/feedback_service.py

@@ -0,0 +1,41 @@
+"""公开反馈提交业务"""
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy.orm import Session
+
+from app.core.response import AppHTTPError
+from app.crud import feedback as feedback_crud
+
+
+def submit_feedback(
+    db: Session,
+    *,
+    fb_type: str,
+    content: str,
+    target_name: Optional[str] = None,
+    target_dept: Optional[str] = None,
+    target_post: Optional[str] = None,
+    reporter_name: Optional[str] = None,
+    reporter_phone: Optional[str] = None,
+    attachment_urls: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+    if not (content or "").strip():
+        raise AppHTTPError(400, "内容不能为空")
+    if not (fb_type or "").strip():
+        raise AppHTTPError(400, "反馈类型不能为空")
+
+    biz_no = feedback_crud.next_biz_no(db, "FK")
+    row = feedback_crud.create_feedback(
+        db,
+        biz_no=biz_no,
+        fb_type=fb_type.strip(),
+        content=content.strip(),
+        target_name=target_name,
+        target_dept=target_dept,
+        target_post=target_post,
+        reporter_name=reporter_name,
+        reporter_phone=reporter_phone,
+        attachments=attachment_urls,
+        status="待处理",
+    )
+    return {"id": row.id, "bizNo": row.biz_no}

+ 35 - 0
backend/app/services/suggestion_service.py

@@ -0,0 +1,35 @@
+"""公开建言提交业务"""
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy.orm import Session
+
+from app.core.response import AppHTTPError
+from app.crud import suggestion as suggestion_crud
+
+
+def submit_suggestion(
+    db: Session,
+    *,
+    content: str,
+    contact_name: Optional[str] = None,
+    contact_unit: Optional[str] = None,
+    contact_post: Optional[str] = None,
+    contact_phone: Optional[str] = None,
+    attachment_urls: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+    if not (content or "").strip():
+        raise AppHTTPError(400, "内容不能为空")
+
+    biz_no = suggestion_crud.next_biz_no(db, "JY")
+    row = suggestion_crud.create_suggestion(
+        db,
+        biz_no=biz_no,
+        content=content.strip(),
+        contact_name=contact_name,
+        contact_unit=contact_unit,
+        contact_post=contact_post,
+        contact_phone=contact_phone,
+        attachments=attachment_urls,
+        status="待查阅",
+    )
+    return {"id": row.id, "bizNo": row.biz_no}

+ 101 - 0
backend/tests/test_public_feedback.py

@@ -0,0 +1,101 @@
+"""Public feedback / suggestion / hotline / qrcode APIs."""
+from app.models.business import Feedback, Suggestion
+from app.services import feedback_service, suggestion_service
+from app.services.seed import seed_if_empty
+
+
+def test_submit_feedback(client):
+    r = client.post(
+        "/api/public/feedback",
+        json={"fbType": "投诉", "content": "测试投诉内容"},
+    )
+    assert r.status_code == 200
+    data = r.json()["data"]
+    assert data["bizNo"].startswith("FK")
+    assert data["id"]
+
+
+def test_feedback_requires_content(client):
+    r = client.post("/api/public/feedback", json={"fbType": "投诉", "content": ""})
+    assert r.status_code == 400
+
+
+def test_feedback_missing_content_is_400(client):
+    r = client.post("/api/public/feedback", json={"fbType": "投诉"})
+    assert r.status_code == 400
+    body = r.json()
+    assert body["code"] == 400
+    assert "detail" not in body
+
+
+def test_get_hotline(client):
+    assert client.get("/api/public/hotline").json()["code"] == 200
+
+
+def test_submit_suggestion(client):
+    r = client.post(
+        "/api/public/suggestion",
+        json={"content": "测试建言内容"},
+    )
+    assert r.status_code == 200
+    data = r.json()["data"]
+    assert data["bizNo"].startswith("JY")
+    assert data["id"]
+
+
+def test_suggestion_requires_content(client):
+    r = client.post("/api/public/suggestion", json={"content": ""})
+    assert r.status_code == 400
+
+
+def test_get_qrcode(client):
+    r = client.get("/api/public/qrcode")
+    assert r.status_code == 200
+    body = r.json()
+    assert body["code"] == 200
+    assert "landUrl" in body["data"]
+
+
+def test_feedback_biz_no_daily_seq(client):
+    r1 = client.post(
+        "/api/public/feedback",
+        json={"fbType": "表扬", "content": "第一条"},
+    )
+    r2 = client.post(
+        "/api/public/feedback",
+        json={"fbType": "表扬", "content": "第二条"},
+    )
+    n1 = r1.json()["data"]["bizNo"]
+    n2 = r2.json()["data"]["bizNo"]
+    assert n1[:-3] == n2[:-3]
+    assert int(n2[-3:]) == int(n1[-3:]) + 1
+
+
+def test_hotline_camel_case_fields(client):
+    data = client.get("/api/public/hotline").json()["data"]
+    assert "phone" in data
+    assert "workTime" in data
+    assert "work_time" not in data
+
+
+def test_feedback_default_status_and_attachments(db_session):
+    seed_if_empty(db_session)
+    result = feedback_service.submit_feedback(
+        db_session,
+        fb_type="投诉",
+        content="带附件投诉",
+        attachment_urls=["/uploads/a.png", "/uploads/b.png"],
+    )
+    row = db_session.query(Feedback).filter(Feedback.id == result["id"]).one()
+    assert row.status == "待处理"
+    assert row.attachments == ["/uploads/a.png", "/uploads/b.png"]
+
+
+def test_suggestion_default_status(db_session):
+    seed_if_empty(db_session)
+    result = suggestion_service.submit_suggestion(
+        db_session,
+        content="建言默认状态",
+    )
+    row = db_session.query(Suggestion).filter(Suggestion.id == result["id"]).one()
+    assert row.status == "待查阅"