feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import require_permissions
|
||||
from app.database import get_db
|
||||
from app.models.faq import FAQ, FAQStatus
|
||||
from app.models.schemas.faq import (
|
||||
FAQBulkDeleteRequest,
|
||||
FAQBulkStatusRequest,
|
||||
FAQCreate,
|
||||
FAQListResponse,
|
||||
FAQResponse,
|
||||
FAQUpdate,
|
||||
)
|
||||
from app.models.schemas.pagination import Pagination
|
||||
from app.models.user import User
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.services.help_center_access import check_help_center_access
|
||||
from app.services.image_security import sanitize_image
|
||||
from app.core.params import PageNumber
|
||||
from app.services.help_center_images import (
|
||||
FAQ_IMAGE_FIT_DIM,
|
||||
FAQ_IMAGE_MAX_DIM,
|
||||
FAQ_IMAGE_TYPES,
|
||||
MAX_FAQ_IMAGE_BYTES,
|
||||
store_article_image,
|
||||
)
|
||||
from app.services.help_center_settings import (
|
||||
generate_faq_slug,
|
||||
normalize_url_path,
|
||||
resolve_faq_slug,
|
||||
resolve_faq_url_path,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_owned_faq(faq_id: UUID, current_user: User, db: Session) -> FAQ:
|
||||
faq = FAQRepository(db).get_by_id(faq_id, current_user.organization_id)
|
||||
# 404 for both missing and cross-org ids (don't reveal existence).
|
||||
if not faq:
|
||||
raise HTTPException(status_code=404, detail="FAQ not found")
|
||||
return faq
|
||||
|
||||
|
||||
@router.get("/faqs", response_model=FAQListResponse)
|
||||
async def list_faqs(
|
||||
status: Optional[FAQStatus] = Query(default=None),
|
||||
category: Optional[str] = Query(default=None),
|
||||
q: Optional[str] = Query(default=None, max_length=200),
|
||||
page: PageNumber = 1,
|
||||
page_size: int = Query(default=100, ge=1, le=200),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
items, total = FAQRepository(db).list_for_org(
|
||||
current_user.organization_id,
|
||||
status=status,
|
||||
category=category,
|
||||
search=q,
|
||||
skip=(page - 1) * page_size,
|
||||
limit=page_size,
|
||||
)
|
||||
return FAQListResponse(
|
||||
faqs=[FAQResponse.model_validate(item) for item in items],
|
||||
pagination=Pagination.build(total=total, page=page, page_size=page_size),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/faqs/categories", response_model=List[str])
|
||||
async def list_categories(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return FAQRepository(db).get_categories(current_user.organization_id)
|
||||
|
||||
|
||||
@router.post("/faqs", response_model=FAQResponse, status_code=201)
|
||||
async def create_faq(
|
||||
payload: FAQCreate,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
org_id = current_user.organization_id
|
||||
faq = FAQ(
|
||||
organization_id=org_id,
|
||||
question=payload.question,
|
||||
answer=payload.answer,
|
||||
category=payload.category,
|
||||
status=payload.status,
|
||||
# A hand-typed slug is normalized and de-duplicated the same way a
|
||||
# generated one is; omitting it derives the slug from the question.
|
||||
slug=(
|
||||
resolve_faq_slug(db, org_id, payload.slug)
|
||||
if payload.slug
|
||||
else generate_faq_slug(db, org_id, payload.question)
|
||||
),
|
||||
url_path=_resolve_url_path(db, org_id, payload.url_path),
|
||||
meta_title=payload.meta_title,
|
||||
meta_description=payload.meta_description,
|
||||
source_label="Added manually",
|
||||
created_by=current_user.id,
|
||||
)
|
||||
return FAQResponse.model_validate(FAQRepository(db).create(faq))
|
||||
|
||||
|
||||
def _resolve_url_path(
|
||||
db: Session, org_id: UUID, requested: Optional[str], exclude_id: Optional[UUID] = None
|
||||
) -> Optional[str]:
|
||||
"""Validate a hand-typed preserved URL path, or raise. None passes through
|
||||
(no path requested / cleared). Unlike the slug this is never auto-corrected:
|
||||
an altered path is no longer the URL the org migrated in, so a bad one is
|
||||
surfaced instead of silently changed."""
|
||||
if not requested:
|
||||
return None
|
||||
if normalize_url_path(requested) is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="That isn't a usable URL path — it can't be empty, or start with a "
|
||||
"reserved prefix like /a/, /ask or /api.",
|
||||
)
|
||||
resolved = resolve_faq_url_path(db, org_id, requested, exclude_id=exclude_id)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=409, detail="Another article already uses that URL path.")
|
||||
return resolved
|
||||
|
||||
|
||||
@router.post("/faqs/image")
|
||||
async def upload_faq_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Upload an image for embedding in an article's Markdown. Returns a stable
|
||||
absolute URL the editor inserts as ."""
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
content = await file.read()
|
||||
# Validate, bomb-guard, downscale and re-encode from decoded pixels — strips
|
||||
# metadata/payloads and normalises to PNG/JPEG (content type is derived here,
|
||||
# never from the client filename).
|
||||
safe_bytes, content_type, _ext = sanitize_image(
|
||||
content,
|
||||
allowed_content_types=set(FAQ_IMAGE_TYPES),
|
||||
max_bytes=MAX_FAQ_IMAGE_BYTES,
|
||||
max_dim=FAQ_IMAGE_MAX_DIM,
|
||||
fit=FAQ_IMAGE_FIT_DIM,
|
||||
)
|
||||
return {"url": await store_article_image(safe_bytes, content_type)}
|
||||
|
||||
|
||||
@router.put("/faqs/{faq_id}", response_model=FAQResponse)
|
||||
async def update_faq(
|
||||
faq_id: UUID,
|
||||
payload: FAQUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
faq = _get_owned_faq(faq_id, current_user, db)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
# The slug is never taken verbatim from the client — normalize and dedupe it
|
||||
# (excluding this row, so re-saving an unchanged slug isn't a self-collision).
|
||||
requested_slug = updates.pop("slug", None)
|
||||
# Note the asymmetry with the slug: a blank slug is a no-op (an article must
|
||||
# always have one), but a blank url_path CLEARS it, moving the article back
|
||||
# to /a/{slug} — so this branches on presence, not truthiness.
|
||||
has_url_path = "url_path" in updates
|
||||
requested_url_path = updates.pop("url_path", None)
|
||||
for field, value in updates.items():
|
||||
setattr(faq, field, value)
|
||||
if requested_slug:
|
||||
faq.slug = resolve_faq_slug(
|
||||
db, current_user.organization_id, requested_slug, exclude_id=faq.id
|
||||
)
|
||||
if has_url_path:
|
||||
faq.url_path = _resolve_url_path(
|
||||
db, current_user.organization_id, requested_url_path, exclude_id=faq.id
|
||||
)
|
||||
# Backfill a slug for legacy/generated FAQs the first time they're edited;
|
||||
# otherwise keep it stable so published article URLs never change on their own.
|
||||
if not faq.slug:
|
||||
faq.slug = generate_faq_slug(db, current_user.organization_id, faq.question)
|
||||
return FAQResponse.model_validate(FAQRepository(db).update(faq))
|
||||
|
||||
|
||||
@router.delete("/faqs/{faq_id}", status_code=204)
|
||||
async def delete_faq(
|
||||
faq_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
faq = _get_owned_faq(faq_id, current_user, db)
|
||||
FAQRepository(db).delete(faq)
|
||||
|
||||
|
||||
@router.post("/faqs/bulk-status")
|
||||
async def bulk_set_status(
|
||||
payload: FAQBulkStatusRequest,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Publish/unpublish one or many FAQs in a single call."""
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
updated = FAQRepository(db).bulk_set_status(
|
||||
current_user.organization_id, payload.faq_ids, payload.status
|
||||
)
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
@router.post("/faqs/bulk-delete")
|
||||
async def bulk_delete(
|
||||
payload: FAQBulkDeleteRequest,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete many FAQs in one call (org-scoped; foreign ids are ignored)."""
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
deleted = FAQRepository(db).bulk_delete(current_user.organization_id, payload.faq_ids)
|
||||
return {"deleted": deleted}
|
||||
Reference in new issue
Block a user