Files
Cloud-Tour-to-Libo/app/data_platform/record_service.py

486 lines
17 KiB
Python

from __future__ import annotations
import json
import uuid
from decimal import Decimal
from typing import Any
from fastapi import HTTPException
from psycopg import sql
from psycopg.types.json import Jsonb
from app.config import settings
from app.data_platform.registry import TABLE_DEFINITIONS, TABLE_REGISTRY, FieldDefinition, TableDefinition
from app.data_platform.schema import (
ensure_all_project_databases,
ensure_project_database,
get_project_database,
)
from app.db import get_conn
def _json_safe(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, uuid.UUID):
return str(value)
if hasattr(value, "isoformat"):
return value.isoformat()
return value
def _row_json(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
return {key: _json_safe(value) for key, value in dict(row).items()}
def _table_or_404(table_code: str) -> TableDefinition:
table = TABLE_REGISTRY.get(table_code)
if not table:
raise HTTPException(404, "数据表未注册")
return table
async def _database_or_404(project_id: str) -> dict[str, Any]:
database = await get_project_database(project_id)
if database:
return database
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
sql.SQL(
"SELECT project_id, tenant_id, display_name FROM {}.projects "
"WHERE project_id=%s AND status <> 'archived'"
).format(sql.Identifier(settings.db_schema)),
(project_id,),
)
project = await cur.fetchone()
if not project:
raise HTTPException(404, "项目数据库不存在")
return await ensure_project_database(
str(project["project_id"]),
str(project["tenant_id"]),
str(project["display_name"]),
)
def _coerce_value(field: FieldDefinition, value: Any) -> Any:
if value in ("", None):
return None
if field.data_type == "json":
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError as exc:
raise HTTPException(400, f"{field.label}不是合法 JSON") from exc
return Jsonb(value)
if field.data_type == "boolean":
if isinstance(value, bool):
return value
return str(value).lower() in {"true", "1", "yes", ""}
if field.data_type == "number":
if "INTEGER" in field.sql_type:
try:
return int(value)
except (TypeError, ValueError) as exc:
raise HTTPException(400, f"{field.label}必须是整数") from exc
try:
return Decimal(str(value))
except Exception as exc:
raise HTTPException(400, f"{field.label}必须是数字") from exc
return value
def _validated_payload(
table: TableDefinition,
body: dict[str, Any],
*,
create: bool,
) -> dict[str, Any]:
definitions = {field.code: field for field in table.fields}
unknown = sorted(set(body) - set(definitions))
if unknown:
raise HTTPException(400, f"不允许的字段:{', '.join(unknown)}")
payload: dict[str, Any] = {}
for code, value in body.items():
field = definitions[code]
if not field.editable:
continue
payload[code] = _coerce_value(field, value)
if create:
missing = [
field.label
for field in table.fields
if field.required and payload.get(field.code) in (None, "")
]
if missing:
raise HTTPException(400, f"缺少必填字段:{', '.join(missing)}")
return payload
async def list_databases() -> list[dict[str, Any]]:
await ensure_all_project_databases()
admin_schema = sql.Identifier(settings.db_schema)
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
sql.SQL(
"""
SELECT d.*, p.status AS project_status
FROM {}.project_databases d
JOIN {}.projects p ON p.project_id=d.project_id
WHERE p.status <> 'archived'
ORDER BY
CASE WHEN d.project_id='yunyou_libo' THEN 0 ELSE 1 END,
p.created_at DESC
"""
).format(admin_schema, admin_schema)
)
databases = await cur.fetchall()
result: list[dict[str, Any]] = []
for database in databases:
schema_name = str(database["schema_name"])
table_counts: list[int] = []
latest_updates: list[Any] = []
for table in TABLE_DEFINITIONS:
await cur.execute(
sql.SQL(
"SELECT count(*) AS count, max(updated_at) AS updated_at "
"FROM {}.{} WHERE deleted_at IS NULL"
).format(
sql.Identifier(schema_name),
sql.Identifier(table.code),
)
)
summary = await cur.fetchone()
table_counts.append(int(summary["count"] or 0))
if summary["updated_at"]:
latest_updates.append(summary["updated_at"])
row = _row_json(dict(database)) or {}
row.update(
{
"table_count": len(TABLE_DEFINITIONS),
"record_count": sum(table_counts),
"updated_at": _json_safe(max(latest_updates)) if latest_updates else _json_safe(database["updated_at"]),
}
)
result.append(row)
return result
async def list_tables(project_id: str) -> dict[str, Any]:
database = await _database_or_404(project_id)
schema_name = str(database["schema_name"])
async with get_conn() as conn:
async with conn.cursor() as cur:
tables: list[dict[str, Any]] = []
for definition in TABLE_DEFINITIONS:
await cur.execute(
sql.SQL(
"SELECT count(*) AS count, max(updated_at) AS updated_at "
"FROM {}.{} WHERE deleted_at IS NULL"
).format(
sql.Identifier(schema_name),
sql.Identifier(definition.code),
)
)
summary = await cur.fetchone()
item = definition.as_dict()
item.update(
{
"record_count": int(summary["count"] or 0),
"updated_at": _json_safe(summary["updated_at"]),
}
)
tables.append(item)
return {"database": _row_json(database), "tables": tables}
async def list_records(
project_id: str,
table_code: str,
*,
page: int,
page_size: int,
search: str | None,
sort_field: str | None,
sort_order: str,
) -> dict[str, Any]:
table = _table_or_404(table_code)
database = await _database_or_404(project_id)
schema_name = str(database["schema_name"])
page = max(1, page)
page_size = max(1, min(200, page_size))
offset = (page - 1) * page_size
allowed_sort = {"id", "created_at", "updated_at", *(field.code for field in table.fields if field.sortable)}
order_field = sort_field if sort_field in allowed_sort else "updated_at"
order_keyword = sql.SQL("ASC") if sort_order.lower() == "asc" else sql.SQL("DESC")
where_parts: list[sql.Composable] = [sql.SQL("deleted_at IS NULL")]
params: list[Any] = []
searchable = [field for field in table.fields if field.searchable]
if search and searchable:
pattern = f"%{search.strip()}%"
where_parts.append(
sql.SQL("(")
+ sql.SQL(" OR ").join(
sql.SQL("{}::text ILIKE %s").format(sql.Identifier(field.code))
for field in searchable
)
+ sql.SQL(")")
)
params.extend(pattern for _ in searchable)
where_clause = sql.SQL(" AND ").join(where_parts)
columns = ["id", *(field.code for field in table.fields), "created_at", "updated_at"]
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
sql.SQL("SELECT count(*) AS count FROM {}.{} WHERE {}").format(
sql.Identifier(schema_name),
sql.Identifier(table.code),
where_clause,
),
params,
)
total = int((await cur.fetchone())["count"])
await cur.execute(
sql.SQL(
"SELECT {} FROM {}.{} WHERE {} ORDER BY {} {} LIMIT %s OFFSET %s"
).format(
sql.SQL(", ").join(sql.Identifier(column) for column in columns),
sql.Identifier(schema_name),
sql.Identifier(table.code),
where_clause,
sql.Identifier(order_field),
order_keyword,
),
[*params, page_size, offset],
)
rows = [_row_json(dict(row)) for row in await cur.fetchall()]
return {
"table": table.as_dict(),
"items": rows,
"page": page,
"page_size": page_size,
"total": total,
}
async def _write_audit(
cur,
*,
schema_name: str,
tenant_id: str,
project_id: str,
table_code: str,
record_id: uuid.UUID,
operation: str,
before_data: dict[str, Any] | None,
after_data: dict[str, Any] | None,
actor: str,
) -> None:
await cur.execute(
sql.SQL(
"""
INSERT INTO {}.data_change_logs (
tenant_id, project_id, table_code, record_id, operation,
before_data, after_data, actor
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
"""
).format(sql.Identifier(schema_name)),
(
tenant_id,
project_id,
table_code,
record_id,
operation,
Jsonb(before_data) if before_data is not None else None,
Jsonb(after_data) if after_data is not None else None,
actor,
),
)
record_version = int((after_data or before_data or {}).get("version") or 1)
await cur.execute(
sql.SQL(
"""
INSERT INTO {}.graph_sync_queue (
tenant_id, project_id, table_code, record_id, operation, record_version
) VALUES (%s, %s, %s, %s, %s, %s)
"""
).format(sql.Identifier(schema_name)),
(tenant_id, project_id, table_code, record_id, operation, record_version),
)
async def create_record(
project_id: str,
table_code: str,
body: dict[str, Any],
actor: str,
) -> dict[str, Any]:
table = _table_or_404(table_code)
if not table.allow_create:
raise HTTPException(403, "该表不允许新增")
database = await _database_or_404(project_id)
payload = _validated_payload(table, body, create=True)
record_id = uuid.uuid4()
schema_name = str(database["schema_name"])
tenant_id = str(database["tenant_id"])
columns = ["id", "tenant_id", "project_id", *payload.keys()]
values = [record_id, tenant_id, project_id, *payload.values()]
returning = ["id", *(field.code for field in table.fields), "created_at", "updated_at"]
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
sql.SQL("INSERT INTO {}.{} ({}) VALUES ({}) RETURNING {}").format(
sql.Identifier(schema_name),
sql.Identifier(table.code),
sql.SQL(", ").join(sql.Identifier(column) for column in columns),
sql.SQL(", ").join(sql.Placeholder() for _ in values),
sql.SQL(", ").join(sql.Identifier(column) for column in returning),
),
values,
)
row = _row_json(dict(await cur.fetchone()))
await _write_audit(
cur,
schema_name=schema_name,
tenant_id=tenant_id,
project_id=project_id,
table_code=table_code,
record_id=record_id,
operation="create",
before_data=None,
after_data=row,
actor=actor,
)
await conn.commit()
return row or {}
async def update_record(
project_id: str,
table_code: str,
record_id: str,
body: dict[str, Any],
actor: str,
) -> dict[str, Any]:
table = _table_or_404(table_code)
if not table.allow_update:
raise HTTPException(403, "该表不允许修改")
try:
record_uuid = uuid.UUID(record_id)
except ValueError as exc:
raise HTTPException(400, "记录 ID 格式错误") from exc
database = await _database_or_404(project_id)
payload = _validated_payload(table, body, create=False)
if not payload:
raise HTTPException(400, "没有可修改字段")
schema_name = str(database["schema_name"])
tenant_id = str(database["tenant_id"])
returning = ["id", *(field.code for field in table.fields), "created_at", "updated_at"]
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
sql.SQL("SELECT {} FROM {}.{} WHERE id=%s AND deleted_at IS NULL").format(
sql.SQL(", ").join(sql.Identifier(column) for column in returning),
sql.Identifier(schema_name),
sql.Identifier(table.code),
),
(record_uuid,),
)
before = _row_json(await cur.fetchone())
if not before:
raise HTTPException(404, "记录不存在")
assignments = [
sql.SQL("{}=%s").format(sql.Identifier(column))
for column in payload
]
if any(field.code == "version" for field in table.fields):
assignments.append(sql.SQL("version=version+1"))
assignments.append(sql.SQL("updated_at=now()"))
await cur.execute(
sql.SQL("UPDATE {}.{} SET {} WHERE id=%s RETURNING {}").format(
sql.Identifier(schema_name),
sql.Identifier(table.code),
sql.SQL(", ").join(assignments),
sql.SQL(", ").join(sql.Identifier(column) for column in returning),
),
[*payload.values(), record_uuid],
)
after = _row_json(await cur.fetchone())
await _write_audit(
cur,
schema_name=schema_name,
tenant_id=tenant_id,
project_id=project_id,
table_code=table_code,
record_id=record_uuid,
operation="update",
before_data=before,
after_data=after,
actor=actor,
)
await conn.commit()
return after or {}
async def delete_record(
project_id: str,
table_code: str,
record_id: str,
actor: str,
) -> dict[str, bool]:
table = _table_or_404(table_code)
if not table.allow_delete:
raise HTTPException(403, "该表不允许删除")
try:
record_uuid = uuid.UUID(record_id)
except ValueError as exc:
raise HTTPException(400, "记录 ID 格式错误") from exc
database = await _database_or_404(project_id)
schema_name = str(database["schema_name"])
tenant_id = str(database["tenant_id"])
columns = ["id", *(field.code for field in table.fields), "created_at", "updated_at"]
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
sql.SQL("SELECT {} FROM {}.{} WHERE id=%s AND deleted_at IS NULL").format(
sql.SQL(", ").join(sql.Identifier(column) for column in columns),
sql.Identifier(schema_name),
sql.Identifier(table.code),
),
(record_uuid,),
)
before = _row_json(await cur.fetchone())
if not before:
raise HTTPException(404, "记录不存在")
await cur.execute(
sql.SQL(
"UPDATE {}.{} SET deleted_at=now(), deleted_by=%s, updated_at=now() WHERE id=%s"
).format(
sql.Identifier(schema_name),
sql.Identifier(table.code),
),
(actor, record_uuid),
)
await _write_audit(
cur,
schema_name=schema_name,
tenant_id=tenant_id,
project_id=project_id,
table_code=table_code,
record_id=record_uuid,
operation="delete",
before_data=before,
after_data=None,
actor=actor,
)
await conn.commit()
return {"ok": True}