feat(customer): 网站客户身份签发与长期负责人绑定
可信客户身份由站点后端签发,长期负责人与本次接待人分开保存。
This commit is contained in:
1 parent
164f5f9615
commit
2c43a13774
18 files changed
+3123
No files matched your search
@@ -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))
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
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 fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.core.auth import require_any_permission, require_permissions
|
||||
from app.models.user import User
|
||||
from app.models.agent import Agent
|
||||
from app.repositories.lead_capture import LeadCaptureConfigRepository
|
||||
from app.models.schemas.lead_capture import (
|
||||
LeadCaptureConfigUpdate, LeadCaptureConfigResponse,
|
||||
)
|
||||
|
||||
# Enterprise gating — Lead Capture is a Pro-plan feature where the enterprise
|
||||
# module is installed; OSS/community deployments are unrestricted (same shape as
|
||||
# the MCP/Workflow/Advanced gates, which are all Pro+ only).
|
||||
try:
|
||||
from app.enterprise.repositories.plan import PlanRepository
|
||||
from app.enterprise.services.feature_access import require_accessible_subscription
|
||||
HAS_ENTERPRISE = True
|
||||
except ImportError:
|
||||
HAS_ENTERPRISE = False
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def check_lead_capture_access(current_user: User, db: Session) -> None:
|
||||
"""Gate Lead Capture behind the Pro plan when the enterprise module is present.
|
||||
Requires an accessible subscription whose plan has the 'lead_capture' feature
|
||||
(Pro/Enterprise only — Free and Base do not). OSS deployments are unrestricted."""
|
||||
if not HAS_ENTERPRISE:
|
||||
return
|
||||
# Raises 403 when the org has no accessible plan.
|
||||
subscription = require_accessible_subscription(db, current_user.organization_id)
|
||||
if not PlanRepository(db).check_feature_availability(str(subscription.plan_id), 'lead_capture'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Lead Capture is not available in your current plan. Please upgrade to Pro to access this feature.",
|
||||
)
|
||||
|
||||
|
||||
def _get_owned_agent(agent_id: UUID, current_user: User, db: Session) -> Agent:
|
||||
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||
# Return 404 for both missing and cross-org agents (don't reveal existence).
|
||||
if not agent or agent.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return agent
|
||||
|
||||
|
||||
@router.get("/{agent_id}/lead-capture", response_model=LeadCaptureConfigResponse)
|
||||
async def get_lead_capture_config(
|
||||
agent_id: UUID,
|
||||
current_user: User = Depends(
|
||||
require_any_permission("view_agents", "manage_agents")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Fetch the agent's lead-capture config, lazily creating a default (OFF) row."""
|
||||
try:
|
||||
_get_owned_agent(agent_id, current_user, db)
|
||||
config = LeadCaptureConfigRepository(db).get_or_create(agent_id)
|
||||
return config
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching lead capture config: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch lead capture config")
|
||||
|
||||
|
||||
@router.put("/{agent_id}/lead-capture", response_model=LeadCaptureConfigResponse)
|
||||
async def update_lead_capture_config(
|
||||
agent_id: UUID,
|
||||
data: LeadCaptureConfigUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Full-replace the agent's lead-capture config (whole tab saved at once)."""
|
||||
try:
|
||||
_get_owned_agent(agent_id, current_user, db)
|
||||
check_lead_capture_access(current_user, db)
|
||||
# Assignment target (stored-only in phase 1) must belong to this org — reject
|
||||
# cross-org user ids so the routing FK can never dangle when routing ships.
|
||||
if data.assignment_target_user_id is not None:
|
||||
target = db.query(User).filter(
|
||||
User.id == data.assignment_target_user_id,
|
||||
User.organization_id == current_user.organization_id,
|
||||
).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=400, detail="Assignment target user not found in this organization")
|
||||
config = LeadCaptureConfigRepository(db).update(agent_id, data)
|
||||
return config
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating lead capture config: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to update lead capture config")
|
||||
Reference in new issue
Block a user