Files
WangShen007 e0baca15a9 feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
2026-10-17 22:05:00 +08:00

268 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,
],
}