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,267 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
SEO surface of the public help center: the <head> values (title, description,
|
||||
canonical, Open Graph) and the schema.org JSON-LD graph. Kept out of the route
|
||||
module so every page derives its metadata by the same rules.
|
||||
|
||||
Two URL spaces are in play and must not be mixed up:
|
||||
- ABSOLUTE (live_url-based) for canonical, og:*, sitemap and every JSON-LD
|
||||
@id/url — crawlers and social scrapers need a fetchable address.
|
||||
- RELATIVE (base_path-prefixed) for links the page actually renders, so the
|
||||
same markup works on a subdomain, a custom domain and /help/{slug}.
|
||||
Breadcrumbs carry both, which is how the BreadcrumbList markup is guaranteed to
|
||||
match the visible trail (a Google requirement).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Sequence
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
from app.models.faq import FAQ
|
||||
from app.models.help_center import HelpCenterSettings
|
||||
from app.services.help_center_content import excerpt, to_plain_text
|
||||
from app.services.help_center_settings import live_url
|
||||
|
||||
HOME_CRUMB = "Home"
|
||||
# Meta descriptions get truncated by search engines around here; the excerpt
|
||||
# helper adds an ellipsis rather than cutting mid-word.
|
||||
META_DESCRIPTION_CHARS = 200
|
||||
|
||||
|
||||
def site_url(row: HelpCenterSettings) -> str:
|
||||
"""The help center's public base URL, never trailing-slashed."""
|
||||
return live_url(row).rstrip("/")
|
||||
|
||||
|
||||
def asset_origin(site: str) -> str:
|
||||
"""Scheme + host the site's uploads are served from. Same origin as the
|
||||
site in every mode — but NOT the same prefix in path mode, where the site
|
||||
is {backend}/help/{slug} while uploads stay at {backend}/api/v1/uploads/…"""
|
||||
parts = urlsplit(site)
|
||||
return f"{parts.scheme}://{parts.netloc}"
|
||||
|
||||
|
||||
def absolute_asset_url(stored_url: Optional[str], site: str) -> Optional[str]:
|
||||
"""A stored logo/image URL made absolute for og:image and JSON-LD. S3 URLs
|
||||
are already absolute and pass through; local uploads are relative and get
|
||||
anchored to the serving origin."""
|
||||
if not stored_url:
|
||||
return None
|
||||
if stored_url.startswith("http://") or stored_url.startswith("https://"):
|
||||
return stored_url
|
||||
return f"{asset_origin(site)}{stored_url}"
|
||||
|
||||
|
||||
def _isoformat(value: Optional[datetime]) -> Optional[str]:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def article_title(row: HelpCenterSettings, faq: FAQ) -> str:
|
||||
"""The article's <title>: the org's override, else question + site name."""
|
||||
return faq.meta_title or f"{faq.question} · {row.organization.name} Help Center"
|
||||
|
||||
|
||||
def article_description(faq: FAQ) -> str:
|
||||
"""The article's meta description: the org's override, else an excerpt of
|
||||
the answer (falling back to the question for answers with no prose)."""
|
||||
return faq.meta_description or excerpt(faq.answer, META_DESCRIPTION_CHARS) or faq.question
|
||||
|
||||
|
||||
def index_title(row: HelpCenterSettings) -> str:
|
||||
return row.title or f"{row.organization.name} Help Center"
|
||||
|
||||
|
||||
def index_description(row: HelpCenterSettings) -> str:
|
||||
return row.description or f"Answers to common questions about {row.organization.name}."
|
||||
|
||||
|
||||
def category_url(site: str, category: str) -> str:
|
||||
"""Absolute URL of the index filtered to one category — the middle
|
||||
breadcrumb. Mirrors the ?topic= link the templates render."""
|
||||
return f"{site}/?topic={quote(category)}"
|
||||
|
||||
|
||||
def article_path(faq: FAQ) -> str:
|
||||
"""The root-relative path an article is served at, percent-DECODED.
|
||||
|
||||
An article migrated in with "preserve original URLs" keeps the path it had
|
||||
on the old help center; everything else lives at /a/{slug}. Exactly one of
|
||||
the two is canonical — /a/{slug} 301s to the preserved path when there is
|
||||
one — so this is the single answer to "where does this article live?" and
|
||||
every URL the site emits derives from it."""
|
||||
return faq.url_path or f"/a/{faq.slug}"
|
||||
|
||||
|
||||
def article_href(faq: FAQ, base_path: str = "") -> str:
|
||||
"""What a page renders in href=: the serving prefix plus the ENCODED path."""
|
||||
return f"{base_path}{quote(article_path(faq), safe='/')}"
|
||||
|
||||
|
||||
def article_url(site: str, faq: FAQ) -> str:
|
||||
"""The article's ABSOLUTE URL, for canonical/og:url/sitemap/JSON-LD."""
|
||||
return f"{site}{quote(article_path(faq), safe='/')}"
|
||||
|
||||
|
||||
def breadcrumbs(
|
||||
row: HelpCenterSettings, faq: FAQ, base_path: str, site: str
|
||||
) -> List[dict]:
|
||||
"""Home › Category › Article. Each crumb carries `href` (what the page
|
||||
links to) and `url` (what BreadcrumbList advertises); the last crumb is the
|
||||
current page and is rendered as text, so it has no href."""
|
||||
return [
|
||||
{"label": HOME_CRUMB, "href": f"{base_path}/", "url": f"{site}/"},
|
||||
{
|
||||
"label": faq.category,
|
||||
"href": f"{base_path}/?topic={quote(faq.category)}",
|
||||
"url": category_url(site, faq.category),
|
||||
},
|
||||
{"label": faq.question, "href": None, "url": article_url(site, faq)},
|
||||
]
|
||||
|
||||
|
||||
def _organization_node(row: HelpCenterSettings, site: str, logo_url: Optional[str]) -> dict:
|
||||
node = {
|
||||
"@type": "Organization",
|
||||
"@id": f"{site}/#organization",
|
||||
"name": row.organization.name,
|
||||
"url": f"{site}/",
|
||||
}
|
||||
if logo_url:
|
||||
node["logo"] = {"@type": "ImageObject", "url": logo_url}
|
||||
return node
|
||||
|
||||
|
||||
def _website_node(row: HelpCenterSettings, site: str) -> dict:
|
||||
return {
|
||||
"@type": "WebSite",
|
||||
"@id": f"{site}/#website",
|
||||
"url": f"{site}/",
|
||||
"name": index_title(row),
|
||||
"publisher": {"@id": f"{site}/#organization"},
|
||||
}
|
||||
|
||||
|
||||
def _question_entity(faq: FAQ) -> dict:
|
||||
"""One schema.org Question + acceptedAnswer, from the plain-text form of the
|
||||
(Markdown) answer."""
|
||||
return {
|
||||
"@type": "Question",
|
||||
"name": faq.question,
|
||||
"acceptedAnswer": {"@type": "Answer", "text": to_plain_text(faq.answer)},
|
||||
}
|
||||
|
||||
|
||||
def _breadcrumb_node(crumbs: Sequence[dict], page_url: str) -> dict:
|
||||
return {
|
||||
"@type": "BreadcrumbList",
|
||||
"@id": f"{page_url}#breadcrumb",
|
||||
"itemListElement": [
|
||||
{"@type": "ListItem", "position": i, "name": crumb["label"], "item": crumb["url"]}
|
||||
for i, crumb in enumerate(crumbs, start=1)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def index_json_ld(
|
||||
row: HelpCenterSettings, faqs: Sequence[FAQ], site: str, logo_url: Optional[str]
|
||||
) -> dict:
|
||||
"""The landing page graph: Organization + WebSite + a CollectionPage that is
|
||||
also the FAQPage carrying every published Q&A.
|
||||
|
||||
FAQPage - not QAPage - is the correct type for these owner-authored answers.
|
||||
QAPage models community/forum pages where users submit competing answers and
|
||||
requires answerCount/upvoteCount, which don't apply here. Google retired FAQ
|
||||
rich results, but the markup still describes the page for other consumers.
|
||||
"""
|
||||
page_url = f"{site}/"
|
||||
page: dict = {
|
||||
"@type": ["CollectionPage", "FAQPage"],
|
||||
"@id": f"{page_url}#webpage",
|
||||
"url": page_url,
|
||||
"name": index_title(row),
|
||||
"description": index_description(row),
|
||||
"isPartOf": {"@id": f"{site}/#website"},
|
||||
"about": {"@id": f"{site}/#organization"},
|
||||
}
|
||||
if faqs:
|
||||
page["mainEntity"] = [_question_entity(faq) for faq in faqs]
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
_organization_node(row, site, logo_url),
|
||||
_website_node(row, site),
|
||||
page,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def article_json_ld(
|
||||
row: HelpCenterSettings,
|
||||
faq: FAQ,
|
||||
site: str,
|
||||
crumbs: Sequence[dict],
|
||||
logo_url: Optional[str],
|
||||
image_url: Optional[str],
|
||||
) -> dict:
|
||||
"""The article page graph: Organization + WebSite + WebPage + BreadcrumbList
|
||||
+ TechArticle, wired together by @id.
|
||||
|
||||
TechArticle (not FAQPage) is what a single how-to/help article is: Google
|
||||
dropped FAQ rich results, while BreadcrumbList still renders in search.
|
||||
"""
|
||||
page_url = article_url(site, faq)
|
||||
description = article_description(faq)
|
||||
article: dict = {
|
||||
"@type": "TechArticle",
|
||||
"@id": f"{page_url}#article",
|
||||
"headline": faq.question,
|
||||
"description": description,
|
||||
"articleSection": faq.category,
|
||||
"inLanguage": "en",
|
||||
"mainEntityOfPage": {"@id": f"{page_url}#webpage"},
|
||||
"isPartOf": {"@id": f"{page_url}#webpage"},
|
||||
"author": {"@id": f"{site}/#organization"},
|
||||
"publisher": {"@id": f"{site}/#organization"},
|
||||
}
|
||||
published_at = _isoformat(faq.created_at)
|
||||
# updated_at stays NULL until the first edit, so fall back to the creation
|
||||
# date rather than claiming a modification date older than publication.
|
||||
modified_at = _isoformat(faq.updated_at) or published_at
|
||||
if published_at:
|
||||
article["datePublished"] = published_at
|
||||
if modified_at:
|
||||
article["dateModified"] = modified_at
|
||||
if image_url:
|
||||
article["image"] = image_url
|
||||
web_page = {
|
||||
"@type": "WebPage",
|
||||
"@id": f"{page_url}#webpage",
|
||||
"url": page_url,
|
||||
"name": article_title(row, faq),
|
||||
"description": description,
|
||||
"isPartOf": {"@id": f"{site}/#website"},
|
||||
"breadcrumb": {"@id": f"{page_url}#breadcrumb"},
|
||||
}
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
_organization_node(row, site, logo_url),
|
||||
_website_node(row, site),
|
||||
web_page,
|
||||
_breadcrumb_node(crumbs, page_url),
|
||||
article,
|
||||
],
|
||||
}
|
||||
Reference in new issue
Block a user