feat(models): 组织、用户、角色、客户、会话与消息模型

按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
WangShen007 committed 2026-09-23 22:05:00 +08:00
1 parent 977783c126
commit 9eb8613c05
126 files changed
+17436

No files matched your search

@@ -0,0 +1,25 @@
"""
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 app.repositories.channels.accounts import ChannelAccountRepository
from app.repositories.channels.conversations import ChannelConversationRepository
from app.repositories.channels.agent_config import AgentChannelConfigRepository
__all__ = [
"ChannelAccountRepository",
"ChannelConversationRepository",
"AgentChannelConfigRepository",
]
@@ -0,0 +1,232 @@
"""
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 json
import secrets
from datetime import datetime, timezone
from typing import Iterable, Optional, List
from uuid import UUID
from sqlalchemy.orm import Session
from app.channels.base import SendResult
from app.models.channels import ChannelAccount, ChannelConversation
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.core.security import decrypt_credentials, encrypt_api_key
from app.core.logger import get_logger
logger = get_logger(__name__)
# settings key set while the platform rejects the account's credentials:
# {"error": <platform message>, "at": <ISO time>}. Absent means healthy.
AUTH_FAILURE_SETTING = "auth_failure"
class ChannelAccountRepository:
"""CRUD for connected channel accounts. Credentials are always stored
Fernet-encrypted; use get_credentials()/create_account() so plaintext
secrets never touch the table."""
def __init__(self, db: Session):
self.db = db
def create_account(
self,
organization_id: UUID,
channel_type: str,
external_account_id: str,
credentials: dict,
display_name: Optional[str] = None,
settings: Optional[dict] = None,
) -> ChannelAccount:
try:
account = ChannelAccount(
organization_id=organization_id,
channel_type=channel_type,
external_account_id=external_account_id,
display_name=display_name,
encrypted_credentials=encrypt_api_key(json.dumps(credentials)),
webhook_secret=secrets.token_urlsafe(32),
settings=settings or {},
)
self.db.add(account)
self.db.commit()
self.db.refresh(account)
logger.info(f"Created {channel_type} channel account for org {organization_id}")
return account
except Exception as e:
logger.error(f"Error creating channel account: {str(e)}")
self.db.rollback()
raise
def get_by_id(self, account_id: UUID) -> Optional[ChannelAccount]:
try:
if isinstance(account_id, str):
account_id = UUID(account_id)
return self.db.query(ChannelAccount).filter(
ChannelAccount.id == account_id
).first()
except Exception as e:
logger.error(f"Error getting channel account: {str(e)}")
return None
def get_by_external_id(self, channel_type: str, external_account_id: str) -> Optional[ChannelAccount]:
"""Resolve the account a webhook payload belongs to."""
try:
return self.db.query(ChannelAccount).filter(
ChannelAccount.channel_type == channel_type,
ChannelAccount.external_account_id == external_account_id,
).first()
except Exception as e:
logger.error(f"Error getting channel account by external id: {str(e)}")
return None
def list_by_org(self, organization_id: UUID, channel_type: Optional[str] = None) -> List[ChannelAccount]:
try:
query = self.db.query(ChannelAccount).filter(
ChannelAccount.organization_id == organization_id
)
if channel_type:
query = query.filter(ChannelAccount.channel_type == channel_type)
return query.order_by(ChannelAccount.created_at).all()
except Exception as e:
logger.error(f"Error listing channel accounts: {str(e)}")
return []
def get_credentials(self, account: ChannelAccount) -> dict:
"""Decrypt an account's credential blob."""
return decrypt_credentials(account.encrypted_credentials)
def update_settings(self, account: ChannelAccount, updates: dict,
remove: Iterable[str] = ()) -> ChannelAccount:
"""Merge non-secret settings into the account (and drop `remove`)."""
merged = {**(account.settings or {}), **updates}
for key in remove:
merged.pop(key, None)
try:
account.settings = merged
self.db.commit()
self.db.refresh(account)
return account
except Exception as e:
logger.error(f"Error updating channel account settings: {str(e)}")
self.db.rollback()
raise
def update_credentials(self, account: ChannelAccount, credentials: dict) -> ChannelAccount:
try:
account.encrypted_credentials = encrypt_api_key(json.dumps(credentials))
# New credentials are a fresh chance; the next send proves them
account.settings = _without_auth_failure(account.settings)
self.db.commit()
self.db.refresh(account)
return account
except Exception as e:
logger.error(f"Error updating channel account credentials: {str(e)}")
self.db.rollback()
raise
def track_send_result(self, account: ChannelAccount, result: SendResult) -> None:
"""Keep the account's needs-reconnect flag in step with what the
platform just said about its credentials."""
if result.auth_failed:
self.record_auth_failure(account, result.error)
elif result.ok:
self.clear_auth_failure(account)
def record_auth_failure(self, account: ChannelAccount, error: Optional[str]) -> None:
"""Mark the account as needing reconnection after the platform refused
its credentials. Keeps the first failure's time so the UI can say since
when."""
if needs_reconnect(account):
return
logger.warning(f"{account.channel_type} account {account.id} needs reconnecting: {error}")
self._update_auth_failure(account, {AUTH_FAILURE_SETTING: {
"error": error, "at": datetime.now(timezone.utc).isoformat()}})
def clear_auth_failure(self, account: ChannelAccount) -> None:
"""A send went through, so the credentials work again."""
if needs_reconnect(account):
self._update_auth_failure(account, {}, remove=(AUTH_FAILURE_SETTING,))
def _update_auth_failure(self, account: ChannelAccount, updates: dict,
remove: Iterable[str] = ()) -> None:
# Bookkeeping beside a send: failing to record it must not fail the send
try:
self.update_settings(account, updates, remove=remove)
except Exception:
pass
def set_active(self, account: ChannelAccount, is_active: bool) -> ChannelAccount:
try:
account.is_active = is_active
self.db.commit()
self.db.refresh(account)
return account
except Exception as e:
logger.error(f"Error updating channel account state: {str(e)}")
self.db.rollback()
raise
def _close_open_sessions(self, account: ChannelAccount) -> None:
"""Close the sessions belonging to this account's conversations.
Deleting the account cascade-deletes its channel_conversations, but the
sessions they point at are not owned by the account and would survive as
open-but-unreachable: an agent could take one over while the customer's
next message — finding no conversation — starts a fresh thread that the
AI answers. Closing them first keeps the two from diverging.
"""
session_ids = [
row.session_id
for row in self.db.query(ChannelConversation.session_id)
.filter(ChannelConversation.channel_account_id == account.id)
.all()
]
if not session_ids:
return
closed = (
self.db.query(SessionToAgent)
.filter(
SessionToAgent.session_id.in_(session_ids),
SessionToAgent.status != SessionStatus.CLOSED,
)
.update({SessionToAgent.status: SessionStatus.CLOSED},
synchronize_session=False)
)
if closed:
logger.info(f"Closed {closed} open session(s) for disconnected "
f"{account.channel_type} account {account.id}")
def delete(self, account: ChannelAccount) -> bool:
try:
self._close_open_sessions(account)
self.db.delete(account)
self.db.commit()
return True
except Exception as e:
logger.error(f"Error deleting channel account: {str(e)}")
self.db.rollback()
return False
def needs_reconnect(account: ChannelAccount) -> bool:
return bool((account.settings or {}).get(AUTH_FAILURE_SETTING))
def _without_auth_failure(settings: Optional[dict]) -> dict:
# A new dict, not an in-place pop: the JSON column only notices reassignment
return {k: v for k, v in (settings or {}).items() if k != AUTH_FAILURE_SETTING}
@@ -0,0 +1,103 @@
"""
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, List
from uuid import UUID
from sqlalchemy.orm import Session
from app.models.channels import AgentChannelConfig
from app.core.logger import get_logger
logger = get_logger(__name__)
class AgentChannelConfigRepository:
def __init__(self, db: Session):
self.db = db
def get_by_account(self, channel_account_id: UUID) -> Optional[AgentChannelConfig]:
try:
return self.db.query(AgentChannelConfig).filter(
AgentChannelConfig.channel_account_id == channel_account_id
).first()
except Exception as e:
logger.error(f"Error getting agent channel config: {str(e)}")
return None
def get_active_agent_id(self, channel_account_id: UUID) -> Optional[UUID]:
"""Agent that answers inbound messages on this account, if enabled."""
config = self.get_by_account(channel_account_id)
if config and config.is_active:
return config.agent_id
return None
def map_by_accounts(self, channel_account_ids: List[UUID]) -> dict:
"""Configs for many accounts in one query, keyed by channel_account_id."""
if not channel_account_ids:
return {}
try:
configs = self.db.query(AgentChannelConfig).filter(
AgentChannelConfig.channel_account_id.in_(channel_account_ids)
).all()
return {config.channel_account_id: config for config in configs}
except Exception as e:
logger.error(f"Error mapping agent channel configs: {str(e)}")
return {}
def list_by_agent(self, agent_id: UUID) -> List[AgentChannelConfig]:
try:
return self.db.query(AgentChannelConfig).filter(
AgentChannelConfig.agent_id == agent_id
).all()
except Exception as e:
logger.error(f"Error listing agent channel configs: {str(e)}")
return []
def set_agent(self, channel_account_id: UUID, agent_id: UUID, is_active: bool = True) -> AgentChannelConfig:
"""Upsert the single agent mapping for a channel account."""
try:
config = self.get_by_account(channel_account_id)
if config:
config.agent_id = agent_id
config.is_active = is_active
else:
config = AgentChannelConfig(
channel_account_id=channel_account_id,
agent_id=agent_id,
is_active=is_active,
)
self.db.add(config)
self.db.commit()
self.db.refresh(config)
return config
except Exception as e:
logger.error(f"Error setting agent channel config: {str(e)}")
self.db.rollback()
raise
def delete_by_account(self, channel_account_id: UUID) -> bool:
try:
config = self.get_by_account(channel_account_id)
if not config:
return False
self.db.delete(config)
self.db.commit()
return True
except Exception as e:
logger.error(f"Error deleting agent channel config: {str(e)}")
self.db.rollback()
return False
@@ -0,0 +1,163 @@
"""
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 datetime import datetime, timezone
from typing import Iterable, Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.models.channels import ChannelConversation
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.core.logger import get_logger
logger = get_logger(__name__)
class ChannelConversationRepository:
def __init__(self, db: Session):
self.db = db
def get_active(self, channel_account_id: UUID, external_conversation_id: str) -> Optional[ChannelConversation]:
"""Latest conversation row for a platform conversation whose session
is still open/transferred. Returns None when a new session is needed."""
try:
return (
self.db.query(ChannelConversation)
.join(SessionToAgent, ChannelConversation.session_id == SessionToAgent.session_id)
.filter(
ChannelConversation.channel_account_id == channel_account_id,
ChannelConversation.external_conversation_id == external_conversation_id,
SessionToAgent.status != SessionStatus.CLOSED,
)
.order_by(ChannelConversation.created_at.desc())
.first()
)
except Exception as e:
logger.error(f"Error getting active channel conversation: {str(e)}")
return None
def get_by_session(self, session_id: UUID) -> Optional[ChannelConversation]:
"""Conversation row for a session — the outbound dispatcher's lookup."""
try:
if isinstance(session_id, str):
session_id = UUID(session_id)
return (
self.db.query(ChannelConversation)
.filter(ChannelConversation.session_id == session_id)
.order_by(ChannelConversation.created_at.desc())
.first()
)
except Exception as e:
logger.error(f"Error getting channel conversation by session: {str(e)}")
return None
def get_latest(self, channel_account_id: UUID, external_conversation_id: str) -> Optional[ChannelConversation]:
"""Most recent conversation row regardless of session status — used for
interactions (rating taps, feedback) that arrive after the chat closed."""
try:
return (
self.db.query(ChannelConversation)
.filter(
ChannelConversation.channel_account_id == channel_account_id,
ChannelConversation.external_conversation_id == external_conversation_id,
)
.order_by(ChannelConversation.created_at.desc())
.first()
)
except Exception as e:
logger.error(f"Error getting latest channel conversation: {str(e)}")
return None
def merge_extra(self, conversation: ChannelConversation, updates: dict,
remove: Iterable[str] = ()) -> Optional[dict]:
"""Set and drop keys in the conversation's extra JSON (per-conversation
state such as email threading headers). Re-reads the row under a lock
first, so an inbound and a send updating different keys at once can't
overwrite each other's. Returns the extra as it was just before this
update (None on failure) — what a caller checks to claim a flag."""
try:
# Anything pending on the instance would be discarded by the re-read
self.db.flush()
current = (
self.db.query(ChannelConversation)
.filter(ChannelConversation.id == conversation.id)
.with_for_update()
.populate_existing()
.one()
)
previous = dict(current.extra or {})
extra = {**previous, **updates}
for key in remove:
extra.pop(key, None)
current.extra = extra
self.db.commit()
return previous
except Exception as e:
logger.error(f"Error updating conversation extra: {str(e)}")
self.db.rollback()
return None
# Sentinel: "created in response to an inbound message" (the default).
# None is a meaningful value — an outbound-started conversation has no
# inbound yet, so no 24h delivery window is open — so it can't be the default.
_INBOUND_NOW = object()
def create(
self,
channel_account_id: UUID,
channel_type: str,
external_conversation_id: str,
external_user_id: str,
session_id: UUID,
organization_id: UUID,
agent_id: Optional[UUID] = None,
customer_id: Optional[UUID] = None,
extra: Optional[dict] = None,
last_inbound_at: Optional[datetime] = _INBOUND_NOW,
) -> ChannelConversation:
try:
if last_inbound_at is self._INBOUND_NOW:
last_inbound_at = datetime.now(timezone.utc)
conversation = ChannelConversation(
channel_account_id=channel_account_id,
channel_type=channel_type,
external_conversation_id=external_conversation_id,
external_user_id=external_user_id,
session_id=session_id,
organization_id=organization_id,
agent_id=agent_id,
customer_id=customer_id,
last_inbound_at=last_inbound_at,
extra=extra or {},
)
self.db.add(conversation)
self.db.commit()
self.db.refresh(conversation)
return conversation
except Exception as e:
logger.error(f"Error creating channel conversation: {str(e)}")
self.db.rollback()
raise
def touch_inbound(self, conversation: ChannelConversation) -> None:
"""Record a fresh customer message; drives delivery-window checks."""
try:
conversation.last_inbound_at = datetime.now(timezone.utc)
self.db.commit()
except Exception as e:
logger.error(f"Error updating conversation inbound timestamp: {str(e)}")
self.db.rollback()