feat(customer): 网站客户身份签发与长期负责人绑定
可信客户身份由站点后端签发,长期负责人与本次接待人分开保存。
This commit is contained in:
1 parent
164f5f9615
commit
2c43a13774
18 files changed
+3123
No files matched your search
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
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 typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.crm.base import CrmAuthError, CrmPushResult, CrmTransientError
|
||||
from app.crm.mapping import build_customer_payload, build_lead_payload
|
||||
from app.crm.registry import get_adapter
|
||||
from app.models.crm import CrmConnectionStatus, CrmSyncJob
|
||||
from app.models.customer import Customer
|
||||
from app.models.lead_capture import CrmSyncTarget, LeadCaptureConfig, LeadCaptureResponse
|
||||
from app.repositories.crm import (
|
||||
CrmConnectionRepository, CrmSyncJobRepository, CrmCustomerSyncRepository,
|
||||
)
|
||||
from app.services.crm_tokens import get_valid_tokens
|
||||
from app.services.feature_gate import feature_allowed
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def enqueue_crm_sync(db: Session, config: LeadCaptureConfig,
|
||||
response: LeadCaptureResponse) -> Optional[CrmSyncJob]:
|
||||
"""Queue a captured lead for CRM push, or quietly decide not to.
|
||||
|
||||
All the "should this sync at all" logic lives here so the capture path
|
||||
stays dumb: no target configured, plan lacks the feature (fails closed),
|
||||
or no active connection → no job. Idempotent per (lead, provider)."""
|
||||
provider = str(config.crm_sync_target or CrmSyncTarget.NONE.value)
|
||||
if provider == CrmSyncTarget.NONE.value:
|
||||
return None
|
||||
if get_adapter(provider) is None:
|
||||
logger.warning(f"Lead {response.id}: unknown CRM target '{provider}', not syncing")
|
||||
return None
|
||||
if not feature_allowed(db, response.organization_id, "crm_sync"):
|
||||
logger.info(f"Lead {response.id}: plan lacks crm_sync, not syncing")
|
||||
return None
|
||||
connection = CrmConnectionRepository(db).get_active(response.organization_id, provider)
|
||||
if connection is None:
|
||||
logger.info(f"Lead {response.id}: no active {provider} connection, not syncing")
|
||||
return None
|
||||
job = CrmSyncJobRepository(db).enqueue(
|
||||
organization_id=response.organization_id,
|
||||
lead_response_id=response.id,
|
||||
provider=provider,
|
||||
agent_id=response.agent_id,
|
||||
)
|
||||
if job is not None:
|
||||
logger.info(f"Queued {provider} sync for lead {response.id}")
|
||||
return job
|
||||
|
||||
|
||||
async def process_job(db: Session, job: CrmSyncJob) -> CrmSyncJob:
|
||||
"""Run one claimed sync job to a terminal or retry state.
|
||||
|
||||
Safe to re-run: the adapters' push sequences converge (HubSpot upsert,
|
||||
Pipedrive search-first + open-lead check)."""
|
||||
jobs = CrmSyncJobRepository(db)
|
||||
|
||||
# Re-checked at run time: the org may have downgraded since enqueue.
|
||||
if not feature_allowed(db, job.organization_id, "crm_sync"):
|
||||
return jobs.skip(job, "plan no longer includes crm_sync")
|
||||
|
||||
connections = CrmConnectionRepository(db)
|
||||
connection = connections.get_active(job.organization_id, job.provider)
|
||||
if connection is None:
|
||||
# Terminal: retrying cannot help until a human reconnects, and the
|
||||
# reconnect path enqueues nothing retroactively — the UI surfaces it.
|
||||
return jobs.fail(job, "no_connection")
|
||||
|
||||
response = db.get(LeadCaptureResponse, job.lead_response_id)
|
||||
if response is None:
|
||||
return jobs.skip(job, "lead response deleted")
|
||||
payload = build_lead_payload(
|
||||
response,
|
||||
_config_for_agent(db, response.agent_id),
|
||||
db.get(Customer, response.customer_id) if response.customer_id else None,
|
||||
)
|
||||
if not payload.email:
|
||||
return jobs.skip(job, "lead has no email")
|
||||
|
||||
adapter = get_adapter(job.provider)
|
||||
try:
|
||||
tokens = await get_valid_tokens(db, connection)
|
||||
except CrmAuthError as e:
|
||||
return jobs.fail(job, f"auth: {e}", connection_id=connection.id)
|
||||
except CrmTransientError as e:
|
||||
return jobs.schedule_retry(job, f"token refresh failed: {e}")
|
||||
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
|
||||
if result.auth_failed:
|
||||
# The stored expiry looked fine but the provider said no — force one
|
||||
# refresh and retry before declaring the connection dead.
|
||||
try:
|
||||
tokens = await get_valid_tokens(db, connection, force_refresh=True)
|
||||
except (CrmAuthError, CrmTransientError) as e:
|
||||
return jobs.fail(job, f"auth: {e}", connection_id=connection.id)
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
if result.auth_failed:
|
||||
connections.set_status(connection, CrmConnectionStatus.EXPIRED.value,
|
||||
last_error=result.error)
|
||||
return jobs.fail(job, f"auth: {result.error}", connection_id=connection.id)
|
||||
|
||||
if result.ok:
|
||||
logger.info(f"Pushed lead {response.id} to {job.provider} "
|
||||
f"({result.action} {result.contact_id})")
|
||||
# Record the per-person link so the People drawer can show it.
|
||||
if response.customer_id:
|
||||
_record_customer_sync(db, job.organization_id, response.customer_id,
|
||||
job.provider, result)
|
||||
return jobs.complete(job, {
|
||||
"action": result.action,
|
||||
"contact_id": result.contact_id,
|
||||
"secondary_id": result.secondary_id,
|
||||
"record_url": result.record_url,
|
||||
}, connection_id=connection.id)
|
||||
if result.retryable:
|
||||
return jobs.schedule_retry(job, result.error or "retryable failure",
|
||||
retry_after_seconds=result.retry_after_seconds)
|
||||
return jobs.fail(job, result.error or "push failed", connection_id=connection.id)
|
||||
|
||||
|
||||
class CrmManualSyncError(Exception):
|
||||
"""A manual "Sync now" could not be completed (surfaced to the user)."""
|
||||
|
||||
|
||||
async def sync_customer_to_crm(db: Session, customer: Customer) -> list:
|
||||
"""Manual "Sync now" from the People drawer: push this person to every
|
||||
active CRM connection in their org and record each link. Synchronous so
|
||||
the UI gets an immediate result. Returns the list of recorded links.
|
||||
|
||||
Raises CrmManualSyncError for user-facing failures (no email, no
|
||||
connection, plan gate, or a definitive provider rejection)."""
|
||||
from app.repositories.customer import CustomerRepository
|
||||
# Anonymous visitors carry a placeholder stand-in (…@noemail.com / .channel)
|
||||
# that can't receive mail — never push it to a CRM.
|
||||
if CustomerRepository.is_placeholder_email(customer.email):
|
||||
raise CrmManualSyncError("Add an email to this person before syncing to a CRM.")
|
||||
if not feature_allowed(db, customer.organization_id, "crm_sync"):
|
||||
raise CrmManualSyncError("CRM sync is not available in your current plan.")
|
||||
|
||||
connections = CrmConnectionRepository(db).list_by_org(customer.organization_id)
|
||||
active = [c for c in connections if c.status == CrmConnectionStatus.ACTIVE.value]
|
||||
if not active:
|
||||
raise CrmManualSyncError("Connect a CRM in Settings → Integrations first.")
|
||||
|
||||
attributes, summary, config = _lead_capture_context(db, customer)
|
||||
payload = build_customer_payload(customer, attributes=attributes, summary=summary, config=config)
|
||||
records, errors = [], []
|
||||
for connection in active:
|
||||
adapter = get_adapter(connection.provider)
|
||||
if adapter is None:
|
||||
continue
|
||||
try:
|
||||
tokens = await get_valid_tokens(db, connection)
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
if result.auth_failed:
|
||||
tokens = await get_valid_tokens(db, connection, force_refresh=True)
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
except CrmAuthError:
|
||||
errors.append(f"{connection.provider}: reconnect required")
|
||||
continue
|
||||
except CrmTransientError:
|
||||
errors.append(f"{connection.provider}: temporary error, try again")
|
||||
continue
|
||||
if result.ok:
|
||||
records.append(_record_customer_sync(
|
||||
db, customer.organization_id, customer.id, connection.provider, result))
|
||||
elif result.auth_failed:
|
||||
connections_repo = CrmConnectionRepository(db)
|
||||
connections_repo.set_status(connection, CrmConnectionStatus.EXPIRED.value,
|
||||
last_error=result.error)
|
||||
errors.append(f"{connection.provider}: reconnect required")
|
||||
else:
|
||||
errors.append(f"{connection.provider}: {result.error or 'push failed'}")
|
||||
|
||||
if not records and errors:
|
||||
raise CrmManualSyncError("; ".join(errors))
|
||||
return records
|
||||
|
||||
|
||||
def _record_customer_sync(db: Session, organization_id, customer_id, provider: str,
|
||||
result: CrmPushResult):
|
||||
return CrmCustomerSyncRepository(db).record(
|
||||
organization_id=organization_id,
|
||||
customer_id=customer_id,
|
||||
provider=provider,
|
||||
contact_id=result.contact_id,
|
||||
secondary_id=result.secondary_id,
|
||||
record_url=result.record_url,
|
||||
)
|
||||
|
||||
|
||||
def _lead_capture_context(db: Session, customer: Customer):
|
||||
"""Merge everything the person's lead captures produced — attributes
|
||||
(latest value wins per key), the most recent AI summary, and the latest
|
||||
response's agent config for custom-field labels — mirroring what the People
|
||||
drawer shows, so a manual push sends the full picture, not just email.
|
||||
Returns (attributes, summary, config)."""
|
||||
responses = (
|
||||
db.query(LeadCaptureResponse)
|
||||
.filter(LeadCaptureResponse.customer_id == customer.id)
|
||||
.order_by(LeadCaptureResponse.created_at)
|
||||
.all()
|
||||
)
|
||||
attributes: dict = {}
|
||||
summary = None
|
||||
latest_agent_id = None
|
||||
for r in responses:
|
||||
for key, value in (r.field_values or {}).items():
|
||||
if value not in (None, ""):
|
||||
attributes[key] = value
|
||||
if r.summary:
|
||||
summary = r.summary
|
||||
if r.agent_id:
|
||||
latest_agent_id = r.agent_id
|
||||
config = _config_for_agent(db, latest_agent_id) if latest_agent_id else None
|
||||
return attributes, summary, config
|
||||
|
||||
|
||||
def _config_for_agent(db: Session, agent_id) -> Optional[LeadCaptureConfig]:
|
||||
if agent_id is None:
|
||||
return None
|
||||
return db.query(LeadCaptureConfig).filter(
|
||||
LeadCaptureConfig.agent_id == agent_id).first()
|
||||
Reference in new issue
Block a user