feat(customer): 网站客户身份签发与长期负责人绑定

可信客户身份由站点后端签发,长期负责人与本次接待人分开保存。
This commit is contained in:
WangShen007 committed 2026-10-10 21:15:00 +08:00
1 parent 164f5f9615
commit 2c43a13774
18 files changed
+3123

No files matched your search

+308
View File
@@ -0,0 +1,308 @@
"""
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.
"""
import base64
import hashlib
import hmac
import secrets
import time
from typing import List
from urllib.parse import urlencode
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.core.auth import get_current_organization, require_permissions
from app.core.config import settings
from app.core.logger import get_logger
from app.core.oauth_state import pop_state, store_state
from app.crm.base import CrmAuthError, CrmTransientError, basic_auth_header
from app.crm.registry import get_adapter
from app.database import get_db
from app.models.crm import CrmConnectionStatus, CrmProvider
from app.models.organization import Organization
from app.models.schemas.crm import CrmConnectionOut, CrmTestResult
from app.models.user import User
from app.repositories.crm import CrmConnectionRepository, CrmSyncJobRepository
from app.services.crm_tokens import get_valid_tokens, tokens_from_connection
from app.services.feature_gate import check_feature_access
router = APIRouter()
logger = get_logger(__name__)
UPGRADE_MESSAGE = "CRM sync is not available in your current plan."
# HubSpot signs webhooks with a timestamp; reject anything older than this.
WEBHOOK_TIMESTAMP_MAX_AGE_SECONDS = 300
def _provider_credentials(provider: str) -> tuple:
return {
CrmProvider.HUBSPOT.value: (settings.HUBSPOT_CLIENT_ID, settings.HUBSPOT_CLIENT_SECRET),
CrmProvider.PIPEDRIVE.value: (settings.PIPEDRIVE_CLIENT_ID, settings.PIPEDRIVE_CLIENT_SECRET),
}.get(provider, ("", ""))
def _require_provider(provider: str) -> None:
if get_adapter(provider) is None:
raise HTTPException(status_code=404, detail="Unknown CRM provider")
def _store_state(state: str, org_id: str, provider: str) -> None:
store_state("crm", state, f"{org_id}:{provider}")
def _pop_state(state: str) -> tuple:
value = pop_state("crm", state)
if not value or ":" not in value:
return None, None
org_id, provider = value.split(":", 1)
return org_id, provider
def _redirect_uri(provider: str) -> str:
return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/crm/{provider}/callback"
def _settings_redirect(provider: str, status: str, reason: str = None) -> RedirectResponse:
params = {"status": status, "integration": provider}
if reason:
params["reason"] = reason
return RedirectResponse(f"{settings.FRONTEND_URL}/settings/integrations?{urlencode(params)}")
def _get_org_connection_or_404(db: Session, organization: Organization, provider: str):
connection = CrmConnectionRepository(db).get_by_org_provider(organization.id, provider)
if connection is None:
raise HTTPException(status_code=404, detail="CRM is not connected")
return connection
@router.get("/connections", response_model=List[CrmConnectionOut])
async def list_connections(
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Connection status per provider for the integrations page, with a
failed-push count so a quietly broken connection is visible."""
check_feature_access(db, organization.id, "crm_sync", UPGRADE_MESSAGE)
job_repo = CrmSyncJobRepository(db)
out = []
for connection in CrmConnectionRepository(db).list_by_org(organization.id):
item = CrmConnectionOut.model_validate(connection)
item.recent_failures = job_repo.count_recent_failures(
organization.id, connection.provider)
out.append(item)
return out
@router.get("/{provider}/install")
async def install_crm(
provider: str,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Start the OAuth install: redirect the browser to the CRM's consent page
with a CSRF state bound to (organization, provider)."""
_require_provider(provider)
check_feature_access(db, organization.id, "crm_sync", UPGRADE_MESSAGE)
client_id, client_secret = _provider_credentials(provider)
if not client_id or not client_secret:
raise HTTPException(status_code=400,
detail=f"{provider} app credentials are not configured")
state = secrets.token_urlsafe(24)
_store_state(state, str(organization.id), provider)
adapter = get_adapter(provider)
return RedirectResponse(adapter.get_authorize_url(state, _redirect_uri(provider)))
@router.get("/{provider}/callback")
async def crm_oauth_callback(
provider: str,
code: str = None,
state: str = None,
error: str = None,
db: Session = Depends(get_db),
):
"""Complete the OAuth install: exchange the code, upsert the connection,
and bounce back to the settings page."""
_require_provider(provider)
if error:
return _settings_redirect(
provider, "failure", "cancelled" if error == "access_denied" else error)
org_id, state_provider = _pop_state(state or "")
if org_id is None or state_provider != provider or not code:
return _settings_redirect(provider, "failure", "invalid_state")
adapter = get_adapter(provider)
try:
tokens = await adapter.exchange_code(code, _redirect_uri(provider))
except (CrmAuthError, CrmTransientError) as e:
logger.error(f"{provider} OAuth exchange failed: {e}")
return _settings_redirect(provider, "failure", "oauth_failed")
repo = CrmConnectionRepository(db)
existing = repo.get_by_external_id(provider, tokens.external_account_id)
if existing is not None and str(existing.organization_id) != org_id:
return _settings_redirect(provider, "failure", "account_connected_elsewhere")
repo.create_or_update(
organization_id=UUID(org_id),
provider=provider,
external_account_id=tokens.external_account_id,
credentials={
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"api_domain": tokens.api_domain,
},
display_name=tokens.display_name,
access_token_expires_at=tokens.expires_at,
refresh_token_expires_at=tokens.refresh_token_expires_at,
)
# A reconnect is the fix for a dead connection — requeue anything that
# terminally failed while it was down so those leads reach the CRM.
CrmSyncJobRepository(db).reopen_failed(UUID(org_id), provider)
logger.info(f"Connected {provider} ({tokens.display_name}) for org {org_id}")
return _settings_redirect(provider, "success")
@router.post("/{provider}/test", response_model=CrmTestResult)
async def test_crm_connection(
provider: str,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Read-only connectivity check — never writes records into the CRM."""
_require_provider(provider)
check_feature_access(db, organization.id, "crm_sync", UPGRADE_MESSAGE)
connection = _get_org_connection_or_404(db, organization, provider)
try:
tokens = await get_valid_tokens(db, connection)
except CrmAuthError:
return CrmTestResult(ok=False, error="Connection expired — please reconnect")
except CrmTransientError as e:
return CrmTestResult(ok=False, error=str(e))
return CrmTestResult(**(await get_adapter(provider).test_connection(tokens)))
@router.delete("/{provider}")
async def disconnect_crm(
provider: str,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Disconnect: best-effort token revocation, drop the row, cancel queued
pushes. Agents' crm_sync_target stays set — it's simply inert until a
reconnect, and the UI warns about the mismatch."""
_require_provider(provider)
connection = _get_org_connection_or_404(db, organization, provider)
repo = CrmConnectionRepository(db)
try:
await get_adapter(provider).revoke(tokens_from_connection(repo, connection))
except Exception as e:
logger.warning(f"{provider} revoke on disconnect failed (ignored): {e}")
CrmSyncJobRepository(db).skip_pending_for_connection(
organization.id, provider, "disconnected")
repo.delete(connection)
return {"status": "disconnected"}
# ---- uninstall webhooks (marketplace requirement) --------------------------
@router.post("/hubspot/uninstall")
async def hubspot_uninstall_webhook(request: Request, db: Session = Depends(get_db)):
"""HubSpot app-uninstall events, authenticated by the v3 signature
(HMAC-SHA256 over method+url+body+timestamp with the client secret)."""
body = await request.body()
if not _valid_hubspot_signature(request, body):
raise HTTPException(status_code=404)
try:
events = (await request.json()) or []
except Exception:
events = []
for event in events if isinstance(events, list) else [events]:
portal_id = str(event.get("portalId", "") or event.get("hub_id", ""))
event_kind = f"{event.get('subscriptionType', '')}{event.get('eventType', '')}".lower()
if portal_id and "uninstall" in event_kind:
_revoke_connection(db, CrmProvider.HUBSPOT.value, portal_id)
return {"ok": True}
@router.post("/pipedrive/uninstall")
async def pipedrive_uninstall_webhook(request: Request, db: Session = Depends(get_db)):
"""Pipedrive app_uninstalled callback, authenticated with HTTP Basic —
the Developer Hub webhook is configured with our client_id:client_secret."""
if not _valid_pipedrive_basic_auth(request.headers.get("Authorization", "")):
raise HTTPException(status_code=404)
try:
payload = await request.json()
except Exception:
payload = {}
company_id = str(payload.get("company_id", ""))
if company_id:
_revoke_connection(db, CrmProvider.PIPEDRIVE.value, company_id)
return {"ok": True}
def _revoke_connection(db: Session, provider: str, external_account_id: str) -> None:
repo = CrmConnectionRepository(db)
connection = repo.get_by_external_id(provider, external_account_id)
if connection is None:
return
repo.set_status(connection, CrmConnectionStatus.REVOKED.value,
last_error="App uninstalled from the CRM")
CrmSyncJobRepository(db).skip_pending_for_connection(
connection.organization_id, provider, "app uninstalled")
logger.info(f"{provider} app uninstalled for org {connection.organization_id}")
def _valid_hubspot_signature(request: Request, body: bytes) -> bool:
"""HubSpot v3: base64(HMAC-SHA256(secret, METHOD + full_url + body + ts)),
with a freshness window against replay."""
signature = request.headers.get("X-HubSpot-Signature-v3", "")
timestamp = request.headers.get("X-HubSpot-Request-Timestamp", "")
if not signature or not timestamp or not settings.HUBSPOT_CLIENT_SECRET:
return False
try:
age_ms = time.time() * 1000 - int(timestamp)
except ValueError:
return False
if age_ms > WEBHOOK_TIMESTAMP_MAX_AGE_SECONDS * 1000:
return False
# HubSpot signs the public https URL; behind the reverse proxy the
# reconstructed URL may say http — accept either scheme, nothing else.
url = str(request.url)
candidate_urls = {url, url.replace("http://", "https://", 1)}
for candidate in candidate_urls:
message = f"{request.method}{candidate}".encode() + body + timestamp.encode()
expected = base64.b64encode(
hmac.new(settings.HUBSPOT_CLIENT_SECRET.encode(), message, hashlib.sha256).digest()
).decode()
if hmac.compare_digest(expected, signature):
return True
return False
def _valid_pipedrive_basic_auth(header: str) -> bool:
client_id, client_secret = _provider_credentials(CrmProvider.PIPEDRIVE.value)
if not client_id or not client_secret or not header.startswith("Basic "):
return False
return hmac.compare_digest(header[6:], basic_auth_header(client_id, client_secret))