feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
977783c126
commit
9eb8613c05
126 files changed
+17436
No files matched your search
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
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 re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
ENGINES = ("postgresql", "mysql")
|
||||
ROW_SCOPE_KEYS = ("email", "phone")
|
||||
|
||||
|
||||
class DBConnectorColumnOut(BaseModel):
|
||||
name: str
|
||||
type: str
|
||||
|
||||
|
||||
class DBConnectorTableOut(BaseModel):
|
||||
schema_name: str = Field(alias="schema")
|
||||
table: str
|
||||
columns: List[DBConnectorColumnOut]
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class SSHTunnelFields(BaseModel):
|
||||
"""Optional SSH bastion/jump-host tunnel. Auth is by private key
|
||||
(preferred) or password; secrets are write-only."""
|
||||
ssh_enabled: bool = False
|
||||
ssh_host: Optional[str] = Field(default=None, max_length=500)
|
||||
ssh_port: int = Field(default=22, ge=1, le=65535)
|
||||
ssh_username: Optional[str] = Field(default=None, max_length=200)
|
||||
ssh_password: Optional[str] = Field(default=None, max_length=1000)
|
||||
ssh_private_key: Optional[str] = Field(default=None, max_length=20000)
|
||||
ssh_private_key_passphrase: Optional[str] = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class DBConnectorDiscoverRequest(SSHTunnelFields):
|
||||
"""Pre-save connection test: credentials go over TLS, are used once for
|
||||
discovery, and are never persisted by this endpoint."""
|
||||
engine: str = Field(pattern=f"^({'|'.join(ENGINES)})$")
|
||||
host: str = Field(min_length=1, max_length=500)
|
||||
port: int = Field(ge=1, le=65535)
|
||||
database: str = Field(min_length=1, max_length=200)
|
||||
username: str = Field(min_length=1, max_length=200)
|
||||
password: str = Field(min_length=1, max_length=500)
|
||||
|
||||
|
||||
class DBConnectorDiscoverResponse(BaseModel):
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
tables: List[DBConnectorTableOut] = []
|
||||
|
||||
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
|
||||
_TABLE_REF = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$")
|
||||
|
||||
ROW_SCOPE_DESCRIPTION = (
|
||||
'Tables restricted to the ticket customer\'s own rows, as '
|
||||
'{"schema.table": "customer_column"}. Listed tables are rewritten into a '
|
||||
"pre-filtered subquery; tables left out are readable in full."
|
||||
)
|
||||
|
||||
|
||||
def validate_row_scope(value: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
||||
"""Both halves land in generated SQL as identifiers. sqlglot quotes them,
|
||||
but a bad value should be refused at the edge rather than quoted and
|
||||
executed — a typo'd column silently scopes nothing useful."""
|
||||
if value is None:
|
||||
return None
|
||||
if len(value) > 500:
|
||||
raise ValueError("Too many row-scope entries")
|
||||
cleaned: Dict[str, str] = {}
|
||||
for table, column in value.items():
|
||||
table = (table or "").strip()
|
||||
column = (column or "").strip()
|
||||
if not table or not column:
|
||||
continue
|
||||
if not _TABLE_REF.match(table):
|
||||
raise ValueError(f"Invalid table reference in row scope: {table!r}")
|
||||
if not _IDENTIFIER.match(column):
|
||||
raise ValueError(f"Invalid column name in row scope: {column!r}")
|
||||
cleaned[table.lower()] = column
|
||||
return cleaned
|
||||
|
||||
|
||||
class DBConnectorCreate(SSHTunnelFields):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
engine: str = Field(pattern=f"^({'|'.join(ENGINES)})$")
|
||||
host: str = Field(min_length=1, max_length=500)
|
||||
port: int = Field(ge=1, le=65535)
|
||||
database: str = Field(min_length=1, max_length=200)
|
||||
username: str = Field(min_length=1, max_length=200)
|
||||
password: str = Field(min_length=1, max_length=500)
|
||||
enabled: bool = True
|
||||
# Nothing is queryable unless explicitly listed ("schema.table").
|
||||
allowed_tables: List[str] = Field(default_factory=list, max_length=500)
|
||||
masked_columns: List[str] = Field(default_factory=list, max_length=500)
|
||||
row_scope: Dict[str, str] = Field(
|
||||
default_factory=dict, description=ROW_SCOPE_DESCRIPTION
|
||||
)
|
||||
row_scope_key: str = Field(
|
||||
default="email", pattern=f"^({'|'.join(ROW_SCOPE_KEYS)})$"
|
||||
)
|
||||
max_rows: int = Field(default=100, ge=1, le=1000)
|
||||
statement_timeout_ms: int = Field(default=5000, ge=100, le=30000)
|
||||
|
||||
@field_validator("row_scope")
|
||||
@classmethod
|
||||
def _check_row_scope(cls, value):
|
||||
return validate_row_scope(value) or {}
|
||||
|
||||
|
||||
class DBConnectorUpdate(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||
host: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||
port: Optional[int] = Field(default=None, ge=1, le=65535)
|
||||
database: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||
username: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||
# Omit to keep the stored password.
|
||||
password: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||
enabled: Optional[bool] = None
|
||||
allowed_tables: Optional[List[str]] = Field(default=None, max_length=500)
|
||||
masked_columns: Optional[List[str]] = Field(default=None, max_length=500)
|
||||
row_scope: Optional[Dict[str, str]] = Field(
|
||||
default=None, description=ROW_SCOPE_DESCRIPTION
|
||||
)
|
||||
row_scope_key: Optional[str] = Field(
|
||||
default=None, pattern=f"^({'|'.join(ROW_SCOPE_KEYS)})$"
|
||||
)
|
||||
max_rows: Optional[int] = Field(default=None, ge=1, le=1000)
|
||||
statement_timeout_ms: Optional[int] = Field(default=None, ge=100, le=30000)
|
||||
# SSH tunnel — omit a secret to keep the stored one.
|
||||
ssh_enabled: Optional[bool] = None
|
||||
ssh_host: Optional[str] = Field(default=None, max_length=500)
|
||||
ssh_port: Optional[int] = Field(default=None, ge=1, le=65535)
|
||||
ssh_username: Optional[str] = Field(default=None, max_length=200)
|
||||
ssh_password: Optional[str] = Field(default=None, max_length=1000)
|
||||
ssh_private_key: Optional[str] = Field(default=None, max_length=20000)
|
||||
ssh_private_key_passphrase: Optional[str] = Field(default=None, max_length=1000)
|
||||
|
||||
@field_validator("row_scope")
|
||||
@classmethod
|
||||
def _check_row_scope(cls, value):
|
||||
return validate_row_scope(value)
|
||||
|
||||
|
||||
class DBConnectorOut(BaseModel):
|
||||
"""Never includes the password in any form."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
name: str
|
||||
engine: str
|
||||
host: str
|
||||
port: int
|
||||
database: str
|
||||
username: str
|
||||
enabled: bool
|
||||
allowed_tables: Optional[List[str]] = None
|
||||
masked_columns: Optional[List[str]] = None
|
||||
row_scope: Optional[Dict[str, str]] = None
|
||||
row_scope_key: str = "email"
|
||||
max_rows: int
|
||||
statement_timeout_ms: int
|
||||
# SSH tunnel — connection details only, never the key/password.
|
||||
ssh_enabled: bool = False
|
||||
ssh_host: Optional[str] = None
|
||||
ssh_port: int = 22
|
||||
ssh_username: Optional[str] = None
|
||||
last_test_at: Optional[datetime] = None
|
||||
last_test_ok: Optional[bool] = None
|
||||
created_at: Optional[datetime] = None
|
||||
Reference in new issue
Block a user