b55af1eff0
- Introduced `requirements.docker.txt` for Docker-specific dependencies. - Updated `requirements.txt` to include `psycopg[binary]` and `python-multipart`. - Enhanced test suite in `test_main_endpoints.py` to cover document CRUD operations. - Modified `docker-compose.yml` to include PostgreSQL and frontend services. - Added Nginx configuration for reverse proxying API requests. - Refactored file handling in Vue components to support new document storage backend. - Created new utility functions in `docsApi.js` for document management. - Updated configuration to support new API endpoints for document operations. - Adjusted Vite configuration to proxy API requests to the local backend.
669 lines
25 KiB
Python
669 lines
25 KiB
Python
import mimetypes
|
|
import os
|
|
import threading
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
try: # pragma: no cover - optional in some test paths
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
except Exception: # pragma: no cover
|
|
psycopg = None
|
|
dict_row = None
|
|
|
|
|
|
MAX_TEXT_SIZE = 8 * 1024 * 1024
|
|
PREVIEW_TEXT_SIZE = 2 * 1024 * 1024
|
|
|
|
TEXT_EXTENSIONS = {
|
|
"md", "markdown", "txt", "json", "js", "jsx", "ts", "tsx",
|
|
"css", "scss", "less", "html", "htm", "py", "vue", "xml",
|
|
"yaml", "yml", "csv", "log", "sql", "toml", "ini", "cfg",
|
|
"conf", "sh", "bat", "ps1", "java", "c", "cpp", "h", "hpp",
|
|
"go", "rs", "swift", "kt", "rb", "php", "pl", "r", "scala",
|
|
"gradle", "properties", "env", "gitignore", "dockerfile",
|
|
}
|
|
|
|
BINARY_EXTENSIONS = {
|
|
"exe", "dll", "so", "dylib", "bin", "dat", "obj", "o", "a",
|
|
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp",
|
|
"pdf", "zip", "rar", "7z", "tar", "gz", "bz2", "xz",
|
|
"png", "jpg", "jpeg", "gif", "bmp", "ico", "webp", "svg",
|
|
"mp3", "mp4", "wav", "avi", "mov", "mkv", "flv", "wmv",
|
|
"ttf", "otf", "woff", "woff2", "eot",
|
|
"class", "pyc", "pyo", "jar", "war", "ear",
|
|
"db", "sqlite", "mdb", "accdb",
|
|
"pem", "key", "crt", "cer", "p12", "pfx", "jks",
|
|
"msg", "eml", "pst", "ost",
|
|
"dwg", "dxf", "step", "stl", "fbx", "3ds", "blend",
|
|
}
|
|
|
|
DEFAULT_MIME_TYPES = {
|
|
"avi": "video/x-msvideo",
|
|
"md": "text/markdown",
|
|
"markdown": "text/markdown",
|
|
"mkv": "video/x-matroska",
|
|
"mov": "video/quicktime",
|
|
"mp4": "video/mp4",
|
|
"txt": "text/plain",
|
|
"json": "application/json",
|
|
"js": "text/javascript",
|
|
"jsx": "text/javascript",
|
|
"ts": "text/typescript",
|
|
"tsx": "text/typescript",
|
|
"css": "text/css",
|
|
"html": "text/html",
|
|
"htm": "text/html",
|
|
"py": "text/x-python",
|
|
"vue": "text/plain",
|
|
"xml": "application/xml",
|
|
"yaml": "text/yaml",
|
|
"yml": "text/yaml",
|
|
"csv": "text/csv",
|
|
"log": "text/plain",
|
|
"sql": "text/plain",
|
|
"toml": "text/plain",
|
|
"ini": "text/plain",
|
|
"cfg": "text/plain",
|
|
"conf": "text/plain",
|
|
"sh": "text/plain",
|
|
"bat": "text/plain",
|
|
"ps1": "text/plain",
|
|
"jpg": "image/jpeg",
|
|
"jpeg": "image/jpeg",
|
|
"flv": "video/x-flv",
|
|
"m4v": "video/x-m4v",
|
|
"png": "image/png",
|
|
"gif": "image/gif",
|
|
"webp": "image/webp",
|
|
"svg": "image/svg+xml",
|
|
"pdf": "application/pdf",
|
|
"ogg": "video/ogg",
|
|
"ogv": "video/ogg",
|
|
"webm": "video/webm",
|
|
"wmv": "video/x-ms-wmv",
|
|
}
|
|
|
|
|
|
def _now_sql() -> str:
|
|
return "CURRENT_TIMESTAMP"
|
|
|
|
|
|
def get_extension(name: str = "") -> str:
|
|
value = str(name or "")
|
|
if "." not in value:
|
|
return value.lower() if value.lower() == "dockerfile" else ""
|
|
return value.rsplit(".", 1)[-1].lower()
|
|
|
|
|
|
def infer_mime_type(name: str, fallback: str = "") -> str:
|
|
if fallback:
|
|
return fallback
|
|
ext = get_extension(name)
|
|
guessed = DEFAULT_MIME_TYPES.get(ext)
|
|
if guessed:
|
|
return guessed
|
|
guessed, _ = mimetypes.guess_type(name)
|
|
return guessed or "application/octet-stream"
|
|
|
|
|
|
def is_text_file(name: str, mime_type: str = "") -> bool:
|
|
ext = get_extension(name)
|
|
if ext in BINARY_EXTENSIONS:
|
|
return False
|
|
mime_value = str(mime_type or "").lower()
|
|
return ext in TEXT_EXTENSIONS or mime_value.startswith("text/") or "json" in mime_value or "xml" in mime_value
|
|
|
|
|
|
def _text_payload(text: str) -> dict[str, Any]:
|
|
encoded = text.encode("utf-8")
|
|
preview = encoded[:PREVIEW_TEXT_SIZE].decode("utf-8", errors="ignore")
|
|
return {
|
|
"storage_kind": "text",
|
|
"size": len(encoded),
|
|
"content_text": text,
|
|
"preview_text": preview if len(encoded) > PREVIEW_TEXT_SIZE else text,
|
|
"is_truncated_preview": len(encoded) > PREVIEW_TEXT_SIZE,
|
|
"blob_data": None,
|
|
}
|
|
|
|
|
|
def prepare_file_payload(name: str, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
|
|
resolved_mime = infer_mime_type(name, mime_type)
|
|
if is_text_file(name, resolved_mime):
|
|
text = raw_bytes.decode("utf-8", errors="ignore")
|
|
return {
|
|
"mime_type": resolved_mime,
|
|
**_text_payload(text),
|
|
}
|
|
return {
|
|
"mime_type": resolved_mime,
|
|
"storage_kind": "blob",
|
|
"size": len(raw_bytes),
|
|
"content_text": "",
|
|
"preview_text": "",
|
|
"is_truncated_preview": False,
|
|
"blob_data": raw_bytes,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class BlobPayload:
|
|
content: bytes
|
|
mime_type: str
|
|
filename: str
|
|
|
|
|
|
class BaseDocumentStore:
|
|
_UNSET = object()
|
|
|
|
def list_nodes(self) -> list[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
def create_folder(self, name: str, parent_id: str | None) -> dict[str, Any]:
|
|
raise NotImplementedError
|
|
|
|
def create_text_file(self, name: str, parent_id: str | None, content: str = "") -> dict[str, Any]:
|
|
raise NotImplementedError
|
|
|
|
def upload_file(self, name: str, parent_id: str | None, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
|
|
raise NotImplementedError
|
|
|
|
def update_node(self, node_id: str, *, name: str | None | object = _UNSET, parent_id: str | None | object = _UNSET, content: str | None | object = _UNSET) -> dict[str, Any]:
|
|
raise NotImplementedError
|
|
|
|
def replace_blob(self, node_id: str, filename: str, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
|
|
raise NotImplementedError
|
|
|
|
def delete_node(self, node_id: str) -> None:
|
|
raise NotImplementedError
|
|
|
|
def get_blob(self, node_id: str) -> BlobPayload:
|
|
raise NotImplementedError
|
|
|
|
|
|
class InMemoryDocumentStore(BaseDocumentStore):
|
|
def __init__(self) -> None:
|
|
self.nodes: dict[str, dict[str, Any]] = {}
|
|
|
|
def _serialize(self, node: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": node["id"],
|
|
"parentId": node["parent_id"],
|
|
"type": node["type"],
|
|
"name": node["name"],
|
|
"mimeType": node.get("mime_type") or "",
|
|
"storageKind": node.get("storage_kind") or "text",
|
|
"size": int(node.get("size") or 0),
|
|
"content": node.get("content_text") or "",
|
|
"previewText": node.get("preview_text") or "",
|
|
"isTruncatedPreview": bool(node.get("is_truncated_preview")),
|
|
"createdAt": int(node["created_at"]),
|
|
"updatedAt": int(node["updated_at"]),
|
|
}
|
|
|
|
def _timestamp(self) -> int:
|
|
import time
|
|
return int(time.time() * 1000)
|
|
|
|
def _get(self, node_id: str) -> dict[str, Any]:
|
|
node = self.nodes.get(node_id)
|
|
if node is None:
|
|
raise KeyError(node_id)
|
|
return node
|
|
|
|
def list_nodes(self) -> list[dict[str, Any]]:
|
|
ordered = sorted(
|
|
self.nodes.values(),
|
|
key=lambda item: (item["type"] != "folder", item["name"].lower(), item["created_at"]),
|
|
)
|
|
return [self._serialize(node) for node in ordered]
|
|
|
|
def create_folder(self, name: str, parent_id: str | None) -> dict[str, Any]:
|
|
now = self._timestamp()
|
|
node = {
|
|
"id": str(uuid.uuid4()),
|
|
"parent_id": parent_id,
|
|
"type": "folder",
|
|
"name": name,
|
|
"mime_type": "",
|
|
"storage_kind": "text",
|
|
"size": 0,
|
|
"content_text": "",
|
|
"preview_text": "",
|
|
"is_truncated_preview": False,
|
|
"blob_data": None,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
self.nodes[node["id"]] = node
|
|
return self._serialize(node)
|
|
|
|
def create_text_file(self, name: str, parent_id: str | None, content: str = "") -> dict[str, Any]:
|
|
now = self._timestamp()
|
|
payload = _text_payload(content)
|
|
node = {
|
|
"id": str(uuid.uuid4()),
|
|
"parent_id": parent_id,
|
|
"type": "file",
|
|
"name": name,
|
|
"mime_type": infer_mime_type(name),
|
|
**payload,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
self.nodes[node["id"]] = node
|
|
return self._serialize(node)
|
|
|
|
def upload_file(self, name: str, parent_id: str | None, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
|
|
now = self._timestamp()
|
|
payload = prepare_file_payload(name, raw_bytes, mime_type)
|
|
node = {
|
|
"id": str(uuid.uuid4()),
|
|
"parent_id": parent_id,
|
|
"type": "file",
|
|
"name": name,
|
|
**payload,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
self.nodes[node["id"]] = node
|
|
return self._serialize(node)
|
|
|
|
def update_node(self, node_id: str, *, name: str | None | object = BaseDocumentStore._UNSET, parent_id: str | None | object = BaseDocumentStore._UNSET, content: str | None | object = BaseDocumentStore._UNSET) -> dict[str, Any]:
|
|
node = self._get(node_id)
|
|
if name is not BaseDocumentStore._UNSET:
|
|
node["name"] = name
|
|
if node["type"] == "file":
|
|
node["mime_type"] = infer_mime_type(name, node.get("mime_type") or "")
|
|
if parent_id is not BaseDocumentStore._UNSET:
|
|
node["parent_id"] = parent_id
|
|
if content is not BaseDocumentStore._UNSET:
|
|
payload = _text_payload(content)
|
|
node.update(payload)
|
|
node["updated_at"] = self._timestamp()
|
|
return self._serialize(node)
|
|
|
|
def replace_blob(self, node_id: str, filename: str, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
|
|
node = self._get(node_id)
|
|
payload = prepare_file_payload(filename, raw_bytes, mime_type)
|
|
node.update(payload)
|
|
node["name"] = filename
|
|
node["mime_type"] = payload["mime_type"]
|
|
node["updated_at"] = self._timestamp()
|
|
return self._serialize(node)
|
|
|
|
def delete_node(self, node_id: str) -> None:
|
|
descendants = {node_id}
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
for node in list(self.nodes.values()):
|
|
if node.get("parent_id") in descendants and node["id"] not in descendants:
|
|
descendants.add(node["id"])
|
|
changed = True
|
|
for current_id in descendants:
|
|
self.nodes.pop(current_id, None)
|
|
|
|
def get_blob(self, node_id: str) -> BlobPayload:
|
|
node = self._get(node_id)
|
|
if node["type"] != "file":
|
|
raise FileNotFoundError(node_id)
|
|
if node.get("blob_data") is not None:
|
|
content = node["blob_data"]
|
|
else:
|
|
content = (node.get("content_text") or "").encode("utf-8")
|
|
return BlobPayload(
|
|
content=content,
|
|
mime_type=node.get("mime_type") or infer_mime_type(node.get("name") or ""),
|
|
filename=node["name"],
|
|
)
|
|
|
|
|
|
class PostgresDocumentStore(BaseDocumentStore):
|
|
def __init__(self, database_url: str) -> None:
|
|
if psycopg is None or dict_row is None:
|
|
raise RuntimeError("psycopg 未安装,无法使用 PostgreSQL 文档存储")
|
|
self.database_url = database_url
|
|
self._init_lock = threading.Lock()
|
|
self._initialized = False
|
|
|
|
def _connect(self):
|
|
return psycopg.connect(self.database_url, autocommit=True, row_factory=dict_row)
|
|
|
|
def _ensure_initialized(self) -> None:
|
|
if self._initialized:
|
|
return
|
|
with self._init_lock:
|
|
if self._initialized:
|
|
return
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS document_nodes (
|
|
id TEXT PRIMARY KEY,
|
|
parent_id TEXT REFERENCES document_nodes(id) ON DELETE CASCADE,
|
|
type TEXT NOT NULL CHECK (type IN ('folder', 'file')),
|
|
name TEXT NOT NULL,
|
|
mime_type TEXT NOT NULL DEFAULT '',
|
|
storage_kind TEXT NOT NULL DEFAULT 'text',
|
|
size BIGINT NOT NULL DEFAULT 0,
|
|
content_text TEXT NOT NULL DEFAULT '',
|
|
preview_text TEXT NOT NULL DEFAULT '',
|
|
is_truncated_preview BOOLEAN NOT NULL DEFAULT FALSE,
|
|
blob_data BYTEA,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
cur.execute(
|
|
"CREATE INDEX IF NOT EXISTS document_nodes_parent_idx ON document_nodes(parent_id)"
|
|
)
|
|
self._initialized = True
|
|
|
|
def _serialize(self, row: dict[str, Any]) -> dict[str, Any]:
|
|
created = row["created_at"]
|
|
updated = row["updated_at"]
|
|
return {
|
|
"id": row["id"],
|
|
"parentId": row["parent_id"],
|
|
"type": row["type"],
|
|
"name": row["name"],
|
|
"mimeType": row.get("mime_type") or "",
|
|
"storageKind": row.get("storage_kind") or "text",
|
|
"size": int(row.get("size") or 0),
|
|
"content": row.get("content_text") or "",
|
|
"previewText": row.get("preview_text") or "",
|
|
"isTruncatedPreview": bool(row.get("is_truncated_preview")),
|
|
"createdAt": int(created.timestamp() * 1000),
|
|
"updatedAt": int(updated.timestamp() * 1000),
|
|
}
|
|
|
|
def _fetch_node(self, node_id: str) -> dict[str, Any]:
|
|
self._ensure_initialized()
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT * FROM document_nodes WHERE id = %s", (node_id,))
|
|
row = cur.fetchone()
|
|
if row is None:
|
|
raise KeyError(node_id)
|
|
return row
|
|
|
|
def list_nodes(self) -> list[dict[str, Any]]:
|
|
self._ensure_initialized()
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
parent_id,
|
|
type,
|
|
name,
|
|
mime_type,
|
|
storage_kind,
|
|
size,
|
|
CASE
|
|
WHEN storage_kind = 'text' AND size <= %s THEN content_text
|
|
ELSE ''
|
|
END AS content_text,
|
|
preview_text,
|
|
is_truncated_preview,
|
|
created_at,
|
|
updated_at
|
|
FROM document_nodes
|
|
ORDER BY
|
|
CASE WHEN type = 'folder' THEN 0 ELSE 1 END,
|
|
LOWER(name),
|
|
created_at
|
|
""",
|
|
(MAX_TEXT_SIZE,),
|
|
)
|
|
return [self._serialize(row) for row in cur.fetchall()]
|
|
|
|
def create_folder(self, name: str, parent_id: str | None) -> dict[str, Any]:
|
|
self._ensure_initialized()
|
|
node_id = str(uuid.uuid4())
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO document_nodes (id, parent_id, type, name, updated_at)
|
|
VALUES (%s, %s, 'folder', %s, CURRENT_TIMESTAMP)
|
|
RETURNING *
|
|
""",
|
|
(node_id, parent_id, name),
|
|
)
|
|
return self._serialize(cur.fetchone())
|
|
|
|
def create_text_file(self, name: str, parent_id: str | None, content: str = "") -> dict[str, Any]:
|
|
self._ensure_initialized()
|
|
node_id = str(uuid.uuid4())
|
|
payload = _text_payload(content)
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO document_nodes (
|
|
id, parent_id, type, name, mime_type, storage_kind, size,
|
|
content_text, preview_text, is_truncated_preview, blob_data, updated_at
|
|
)
|
|
VALUES (%s, %s, 'file', %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
|
RETURNING *
|
|
""",
|
|
(
|
|
node_id,
|
|
parent_id,
|
|
name,
|
|
infer_mime_type(name),
|
|
payload["storage_kind"],
|
|
payload["size"],
|
|
payload["content_text"],
|
|
payload["preview_text"],
|
|
payload["is_truncated_preview"],
|
|
None,
|
|
),
|
|
)
|
|
return self._serialize(cur.fetchone())
|
|
|
|
def upload_file(self, name: str, parent_id: str | None, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
|
|
self._ensure_initialized()
|
|
node_id = str(uuid.uuid4())
|
|
payload = prepare_file_payload(name, raw_bytes, mime_type)
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO document_nodes (
|
|
id, parent_id, type, name, mime_type, storage_kind, size,
|
|
content_text, preview_text, is_truncated_preview, blob_data, updated_at
|
|
)
|
|
VALUES (%s, %s, 'file', %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
|
RETURNING *
|
|
""",
|
|
(
|
|
node_id,
|
|
parent_id,
|
|
name,
|
|
payload["mime_type"],
|
|
payload["storage_kind"],
|
|
payload["size"],
|
|
payload["content_text"],
|
|
payload["preview_text"],
|
|
payload["is_truncated_preview"],
|
|
payload["blob_data"],
|
|
),
|
|
)
|
|
return self._serialize(cur.fetchone())
|
|
|
|
def update_node(self, node_id: str, *, name: str | None | object = BaseDocumentStore._UNSET, parent_id: str | None | object = BaseDocumentStore._UNSET, content: str | None | object = BaseDocumentStore._UNSET) -> dict[str, Any]:
|
|
self._ensure_initialized()
|
|
row = self._fetch_node(node_id)
|
|
next_name = row["name"] if name is BaseDocumentStore._UNSET else name
|
|
next_parent_id = row["parent_id"] if parent_id is BaseDocumentStore._UNSET else parent_id
|
|
next_mime_type = row.get("mime_type") or ""
|
|
next_storage_kind = row.get("storage_kind") or "text"
|
|
next_size = row.get("size") or 0
|
|
next_content_text = row.get("content_text") or ""
|
|
next_preview_text = row.get("preview_text") or ""
|
|
next_is_truncated = bool(row.get("is_truncated_preview"))
|
|
next_blob_data = row.get("blob_data")
|
|
|
|
if row["type"] == "file" and name is not BaseDocumentStore._UNSET:
|
|
next_mime_type = infer_mime_type(next_name, next_mime_type)
|
|
if content is not BaseDocumentStore._UNSET:
|
|
payload = _text_payload(content)
|
|
next_storage_kind = payload["storage_kind"]
|
|
next_size = payload["size"]
|
|
next_content_text = payload["content_text"]
|
|
next_preview_text = payload["preview_text"]
|
|
next_is_truncated = payload["is_truncated_preview"]
|
|
next_blob_data = None
|
|
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE document_nodes
|
|
SET
|
|
name = %s,
|
|
parent_id = %s,
|
|
mime_type = %s,
|
|
storage_kind = %s,
|
|
size = %s,
|
|
content_text = %s,
|
|
preview_text = %s,
|
|
is_truncated_preview = %s,
|
|
blob_data = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
RETURNING *
|
|
""",
|
|
(
|
|
next_name,
|
|
next_parent_id,
|
|
next_mime_type,
|
|
next_storage_kind,
|
|
next_size,
|
|
next_content_text,
|
|
next_preview_text,
|
|
next_is_truncated,
|
|
next_blob_data,
|
|
node_id,
|
|
),
|
|
)
|
|
updated = cur.fetchone()
|
|
if updated is None:
|
|
raise KeyError(node_id)
|
|
return self._serialize(updated)
|
|
|
|
def replace_blob(self, node_id: str, filename: str, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
|
|
self._ensure_initialized()
|
|
payload = prepare_file_payload(filename, raw_bytes, mime_type)
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE document_nodes
|
|
SET
|
|
name = %s,
|
|
mime_type = %s,
|
|
storage_kind = %s,
|
|
size = %s,
|
|
content_text = %s,
|
|
preview_text = %s,
|
|
is_truncated_preview = %s,
|
|
blob_data = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s AND type = 'file'
|
|
RETURNING *
|
|
""",
|
|
(
|
|
filename,
|
|
payload["mime_type"],
|
|
payload["storage_kind"],
|
|
payload["size"],
|
|
payload["content_text"],
|
|
payload["preview_text"],
|
|
payload["is_truncated_preview"],
|
|
payload["blob_data"],
|
|
node_id,
|
|
),
|
|
)
|
|
updated = cur.fetchone()
|
|
if updated is None:
|
|
raise KeyError(node_id)
|
|
return self._serialize(updated)
|
|
|
|
def delete_node(self, node_id: str) -> None:
|
|
self._ensure_initialized()
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH RECURSIVE descendants AS (
|
|
SELECT id FROM document_nodes WHERE id = %s
|
|
UNION ALL
|
|
SELECT child.id
|
|
FROM document_nodes child
|
|
INNER JOIN descendants parent ON child.parent_id = parent.id
|
|
)
|
|
DELETE FROM document_nodes
|
|
WHERE id IN (SELECT id FROM descendants)
|
|
""",
|
|
(node_id,),
|
|
)
|
|
|
|
def get_blob(self, node_id: str) -> BlobPayload:
|
|
self._ensure_initialized()
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, name, mime_type, content_text, blob_data
|
|
FROM document_nodes
|
|
WHERE id = %s AND type = 'file'
|
|
""",
|
|
(node_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row is None:
|
|
raise FileNotFoundError(node_id)
|
|
blob_data = row.get("blob_data")
|
|
if blob_data is None:
|
|
content = (row.get("content_text") or "").encode("utf-8")
|
|
else:
|
|
content = bytes(blob_data)
|
|
return BlobPayload(
|
|
content=content,
|
|
mime_type=row.get("mime_type") or infer_mime_type(row.get("name") or ""),
|
|
filename=row["name"],
|
|
)
|
|
|
|
|
|
_document_store: BaseDocumentStore | None = None
|
|
|
|
|
|
def get_document_store() -> BaseDocumentStore:
|
|
global _document_store
|
|
if _document_store is not None:
|
|
return _document_store
|
|
backend = (os.getenv("DOCS_BACKEND") or "postgres").strip().lower()
|
|
if backend == "memory":
|
|
_document_store = InMemoryDocumentStore()
|
|
return _document_store
|
|
|
|
database_url = os.getenv("DATABASE_URL", "").strip()
|
|
if not database_url:
|
|
raise RuntimeError("缺少 DATABASE_URL,无法初始化 PostgreSQL 文档存储")
|
|
_document_store = PostgresDocumentStore(database_url)
|
|
return _document_store
|
|
|
|
|
|
def reset_document_store() -> None:
|
|
global _document_store
|
|
_document_store = None
|