build: 打通 FastAPI 与 Vue 最小工程、依赖与数据库连接
后端入口、配置、Redis 与 Alembic 迁移就位;前端 Vite 工程可安装可构建。
This commit is contained in:
1 parent
fb0ddca4de
commit
2aee6286d6
167 files changed
+26082
No files matched your search
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
# Set environment variable to prevent tokenizer deadlock warnings
|
||||
import os
|
||||
os.environ.setdefault('TOKENIZERS_PARALLELISM', 'false')
|
||||
|
||||
# Add users import
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import socketio
|
||||
from app.api import chat, organizations, users, ai_setup, knowledge, agent, notification, widget, widget_apps, user_groups, roles, analytics, jira, shopify, workflow, workflow_node, mcp_tool, file_upload, token, lead_capture, people, tickets
|
||||
from app.api import help_center as help_center_api
|
||||
from app.api import help_center_images
|
||||
from app.api import channels as channels_api
|
||||
from app.api import webhooks as channel_webhooks
|
||||
# Import widget_chat to register socket.io event handlers for /widget namespace
|
||||
from app.api import widget_chat # noqa: F401 - imported for side effects (socket.io handlers registration)
|
||||
from fastapi import FastAPI, BackgroundTasks
|
||||
from app.core.config import settings, verify_secret_configuration
|
||||
from app.core.encryption import verify_encryption_key
|
||||
from app.services.file_storage import PUBLIC_UPLOAD_FOLDERS
|
||||
from app.services.firebase import initialize_firebase
|
||||
from app.database import engine, Base
|
||||
import asyncio
|
||||
from app.core.logger import get_logger
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
from app.channels.app_hooks import load_extensions
|
||||
from app.core.socketio import socket_app, configure_socketio, sio
|
||||
from app.core.cors import (
|
||||
FirstPartyCredentialsCORSMiddleware,
|
||||
first_party_origins,
|
||||
get_cors_origin_regex,
|
||||
get_cors_origins,
|
||||
with_configured_origins,
|
||||
)
|
||||
from app.core.application import app, initialize_cors_listener
|
||||
from app.core.crawler_policy import install_crawler_policy
|
||||
|
||||
# Import models to ensure they're registered with SQLAlchemy
|
||||
from app.models import Organization, User, Customer
|
||||
try:
|
||||
from app.enterprise.models import OTP
|
||||
except ImportError:
|
||||
print("Enterprise models not available")
|
||||
from app.api import session_to_agent
|
||||
from app.workers.chat_auto_closer import run_auto_closer_loop
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
# First: a bad ENCRYPTION_KEY must stop the boot, not surface as an unreadable
|
||||
# conversation once the app is already serving traffic.
|
||||
verify_encryption_key()
|
||||
verify_secret_configuration()
|
||||
initialize_firebase()
|
||||
await startup_event(app)
|
||||
yield
|
||||
# Shutdown
|
||||
pass
|
||||
|
||||
# Move the CORS setup before app instantiation
|
||||
cors_origins = with_configured_origins(get_cors_origins())
|
||||
logger.debug(f"CORS origins: {cors_origins}")
|
||||
|
||||
# Update the FastAPI app to use the lifespan function
|
||||
app.router.lifespan_context = lifespan
|
||||
|
||||
# Add CORS middleware to FastAPI app. The regex covers every help-center
|
||||
# subdomain (*.chattermate.help) so a newly-created slug is allowed without a
|
||||
# cache refresh; explicit origins still cover org domains + custom domains.
|
||||
# Only first-party origins (the dashboard) may send credentials.
|
||||
app.add_middleware(
|
||||
FirstPartyCredentialsCORSMiddleware,
|
||||
credentialed_origins=first_party_origins(),
|
||||
allow_origins=cors_origins,
|
||||
allow_origin_regex=get_cors_origin_regex(),
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
async def startup_event(api: FastAPI):
|
||||
"""Configure Socket.IO on startup. Called from lifespan() with the FastAPI
|
||||
instance; the on_event registration it used to carry never ran once a
|
||||
custom lifespan was set. The module-level `app` is rebound to the Socket.IO
|
||||
ASGI wrapper below, so the instance has to be passed in."""
|
||||
configure_socketio(cors_origins)
|
||||
|
||||
# Channel extensions (e.g. an external-app integration) hook into message
|
||||
# handling; loaded explicitly so other entry points never pull them in.
|
||||
load_extensions()
|
||||
|
||||
# Start CORS listener for multi-worker synchronization
|
||||
initialize_cors_listener()
|
||||
|
||||
# Start chat auto-closer background task, AI chat will auto close after 1 day.
|
||||
# Held on app.state so the task cannot be garbage-collected mid-run.
|
||||
api.state.auto_closer_task = asyncio.create_task(run_auto_closer_loop())
|
||||
|
||||
# Include routers
|
||||
app.include_router(
|
||||
chat.router,
|
||||
prefix=f"{settings.API_V1_STR}/chats",
|
||||
tags=["chats"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
channels_api.router,
|
||||
prefix=f"{settings.API_V1_STR}/channels",
|
||||
tags=["channels"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
tickets.router,
|
||||
prefix=f"{settings.API_V1_STR}/tickets",
|
||||
tags=["tickets"]
|
||||
)
|
||||
|
||||
from app.api import crm as crm_api
|
||||
app.include_router(
|
||||
crm_api.router,
|
||||
prefix=f"{settings.API_V1_STR}/crm",
|
||||
tags=["crm"]
|
||||
)
|
||||
|
||||
from app.api import ticket_db_connectors, ticket_webhooks
|
||||
app.include_router(
|
||||
ticket_db_connectors.router,
|
||||
prefix=f"{settings.API_V1_STR}/ticket-db-connectors",
|
||||
tags=["tickets"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
ticket_webhooks.router,
|
||||
prefix=f"{settings.API_V1_STR}/tickets/webhooks",
|
||||
tags=["tickets"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
channel_webhooks.router,
|
||||
prefix=f"{settings.API_V1_STR}/webhooks",
|
||||
tags=["webhooks"]
|
||||
)
|
||||
|
||||
# Try to import enterprise module if available
|
||||
try:
|
||||
from app.enterprise import router as enterprise_router
|
||||
app.include_router(enterprise_router, prefix=f"{settings.API_V1_STR}/enterprise", tags=["enterprise"])
|
||||
except ImportError as e:
|
||||
if os.path.exists(os.path.join(os.path.dirname(__file__), "enterprise", "__init__.py")):
|
||||
# The module is there but won't load (e.g. it needs newer core code):
|
||||
# signup and billing would vanish silently, so say so loudly.
|
||||
logger.error(f"Enterprise module present but failed to import: {e}")
|
||||
else:
|
||||
logger.info("Enterprise module not available - running in community edition mode")
|
||||
|
||||
app.include_router(
|
||||
organizations.router,
|
||||
prefix=f"{settings.API_V1_STR}/organizations",
|
||||
tags=["organizations"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
users.router,
|
||||
prefix=f"{settings.API_V1_STR}/users",
|
||||
tags=["users"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
help_center_api.router,
|
||||
prefix=f"{settings.API_V1_STR}/help-center",
|
||||
tags=["help-center"]
|
||||
)
|
||||
|
||||
# Public: article images baked into FAQ Markdown. Same prefix as the admin
|
||||
# router above but unauthenticated — article pages are public. Also registered
|
||||
# on public_app for host mode.
|
||||
app.include_router(
|
||||
help_center_images.router,
|
||||
prefix=f"{settings.API_V1_STR}/help-center",
|
||||
tags=["help-center"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
knowledge.router,
|
||||
prefix=f"{settings.API_V1_STR}/knowledge",
|
||||
tags=["knowledge"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
ai_setup.router,
|
||||
prefix=f"{settings.API_V1_STR}/ai",
|
||||
tags=["ai"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
agent.router,
|
||||
prefix=f"{settings.API_V1_STR}/agent",
|
||||
tags=["agent"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
lead_capture.router,
|
||||
prefix=f"{settings.API_V1_STR}/agent",
|
||||
tags=["lead-capture"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
people.router,
|
||||
prefix=f"{settings.API_V1_STR}/people",
|
||||
tags=["people"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
mcp_tool.router,
|
||||
prefix=f"{settings.API_V1_STR}/mcp-tools",
|
||||
tags=["mcp-tools"]
|
||||
)
|
||||
|
||||
# Proxy router moved to enterprise module
|
||||
|
||||
app.include_router(
|
||||
notification.router,
|
||||
prefix=f"{settings.API_V1_STR}/notifications",
|
||||
tags=["notification"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
widget.router,
|
||||
prefix=f"{settings.API_V1_STR}/widgets",
|
||||
tags=["widget"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
user_groups.router,
|
||||
prefix=f"{settings.API_V1_STR}/groups",
|
||||
tags=["groups"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
roles.router,
|
||||
prefix=f"{settings.API_V1_STR}/roles",
|
||||
tags=["roles"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
session_to_agent.router,
|
||||
prefix=f"{settings.API_V1_STR}/sessions",
|
||||
tags=["session_to_agent"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
analytics.router,
|
||||
prefix=f"{settings.API_V1_STR}/analytics",
|
||||
tags=["analytics"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
jira.router,
|
||||
prefix=f"{settings.API_V1_STR}/jira",
|
||||
tags=["jira"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
token.router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
tags=["token"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
widget_apps.router,
|
||||
prefix=f"{settings.API_V1_STR}/widget-apps",
|
||||
tags=["widget-apps"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
shopify.router,
|
||||
prefix=f"{settings.API_V1_STR}/shopify",
|
||||
tags=["shopify"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
workflow.router,
|
||||
prefix=f"{settings.API_V1_STR}/workflow",
|
||||
tags=["workflow"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
workflow_node.router,
|
||||
prefix=f"{settings.API_V1_STR}/workflow",
|
||||
tags=["workflow_node"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
file_upload.router,
|
||||
prefix=f"{settings.API_V1_STR}/files",
|
||||
tags=["files"]
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"name": settings.PROJECT_NAME,
|
||||
"version": settings.VERSION,
|
||||
"description": "Welcome to ChatterMate API"
|
||||
}
|
||||
|
||||
@app.api_route("/health", methods=["GET"], operation_id="get_health_check")
|
||||
async def get_health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": settings.VERSION
|
||||
}
|
||||
|
||||
@app.api_route("/health", methods=["HEAD"], operation_id="head_health_check")
|
||||
async def head_health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": settings.VERSION
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health/help-center-domain", include_in_schema=False, operation_id="help_center_domain_tls_check")
|
||||
async def help_center_domain_tls_check(domain: str = ""):
|
||||
"""On-demand-TLS "ask" gate for the edge proxy (e.g. Caddy).
|
||||
|
||||
Returns 200 only when ``domain`` is a help-center host we actually serve —
|
||||
a ``{slug}.HELP_CENTER_BASE_DOMAIN`` subdomain or a DB-verified custom
|
||||
domain — so the edge provisions a certificate for those and refuses every
|
||||
other name pointed at us. Public by design (the edge calls it
|
||||
unauthenticated) but it leaks nothing: the answer is a bare status code and
|
||||
the lookup reuses the same in-memory cache/slug check as host dispatch, so
|
||||
there is no per-request database hit.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
from app.core.help_center_host import is_help_center_host, normalize_host
|
||||
|
||||
host = normalize_host(domain)
|
||||
if host and is_help_center_host(host):
|
||||
return Response(status_code=200)
|
||||
return Response(status_code=404)
|
||||
|
||||
|
||||
# Create upload directories if they don't exist
|
||||
if not os.path.exists("uploads"):
|
||||
os.makedirs("uploads")
|
||||
if not os.path.exists("uploads/agents"):
|
||||
os.makedirs("uploads/agents")
|
||||
|
||||
# Only the public upload folders are static-mounted. Chat attachments are
|
||||
# served through signed links (GET {API_V1_STR}/files/local/...), and the rest
|
||||
# of uploads/ is never served. check_dir=False: a folder may not exist until
|
||||
# its first upload.
|
||||
for folder in PUBLIC_UPLOAD_FOLDERS:
|
||||
app.mount(
|
||||
f"{settings.API_V1_STR}/uploads/{folder}",
|
||||
StaticFiles(directory=os.path.join("uploads", folder), check_dir=False),
|
||||
name=f"uploads_{folder}",
|
||||
)
|
||||
app.mount("/assets", StaticFiles(directory=settings.WIDGET_ASSETS_DIR), name="assets")
|
||||
|
||||
# The API origin is not a website: no robots.txt meant crawlers were free to
|
||||
# discover /api/v1/... endpoints and report them as 404s.
|
||||
install_crawler_policy(app)
|
||||
|
||||
# Create final ASGI app
|
||||
# Public help center: requests for {slug}.<help domain> / verified custom
|
||||
# domains are dispatched to the SSR help-center app before reaching the API.
|
||||
from app.api.help_center_public import public_app
|
||||
from app.core.help_center_host import HelpCenterHostMiddleware
|
||||
|
||||
app = socketio.ASGIApp(sio, HelpCenterHostMiddleware(app, public_app))
|
||||
Reference in new issue
Block a user