feat: add project-scoped operations
This commit is contained in:
+27
-10
@@ -8,11 +8,13 @@ from uuid import UUID, uuid4
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.domain.schemas.shared import TimeRange
|
||||
from app.services.content_digest import digest
|
||||
from app.infra.db.postgresql.workbench_events import lock_event_transaction_sync
|
||||
from app.infra.db.project_routing import ActiveProjectRouting
|
||||
|
||||
from .errors import SampleFailure, ensure
|
||||
from .auth import Identity
|
||||
@@ -38,23 +40,37 @@ WORK_ORDER_CREATED_DETAILS = {
|
||||
|
||||
|
||||
class PostgresStore:
|
||||
def __init__(self, settings: SampleSettings) -> None:
|
||||
self.settings = settings
|
||||
def __init__(self, settings: SampleSettings, *, routing: ActiveProjectRouting | None = None) -> None:
|
||||
self.routing = routing
|
||||
# Explicit seed/export scripts retain their configured database. HTTP
|
||||
# dependencies always supply authenticated metadata routing.
|
||||
self.settings = settings if routing is None else settings.model_copy(update={
|
||||
"db_name": routing.business_database_name, "project_code": routing.project_code,
|
||||
})
|
||||
|
||||
@contextmanager
|
||||
def connection(self, *, readonly: bool = False, timeseries: bool = False) -> Iterator[psycopg.Connection]:
|
||||
from app.core.config import settings as product
|
||||
ensure(not timeseries or readonly, "READ_ONLY_SOURCE", "历史监测源仅允许只读连接", 403)
|
||||
prefix = "TIMESCALEDB_DB_" if timeseries else "DB_"
|
||||
if self.routing is not None:
|
||||
dsn = self.routing.timescale_dsn if timeseries else self.routing.business_dsn
|
||||
ensure(bool(dsn), "PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库路由尚未配置", 503)
|
||||
expected_database = conninfo_to_dict(dsn).get("dbname")
|
||||
ensure(bool(expected_database), "PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库名称尚未配置", 503)
|
||||
connect_args = {"conninfo": dsn}
|
||||
else:
|
||||
expected_database = self.settings.db_name
|
||||
connect_args = {"host": getattr(product, prefix + "HOST"), "port": getattr(product, prefix + "PORT"),
|
||||
"user": getattr(product, prefix + "USER"), "password": getattr(product, prefix + "PASSWORD"),
|
||||
"dbname": expected_database}
|
||||
try:
|
||||
with psycopg.connect(host=getattr(product, prefix + "HOST"), port=getattr(product, prefix + "PORT"),
|
||||
user=getattr(product, prefix + "USER"), password=getattr(product, prefix + "PASSWORD"),
|
||||
dbname=self.settings.db_name, connect_timeout=5,
|
||||
with psycopg.connect(**connect_args, connect_timeout=5,
|
||||
options="-c statement_timeout=5000", row_factory=dict_row) as conn:
|
||||
if readonly:
|
||||
conn.execute("SET TRANSACTION READ ONLY")
|
||||
actual = conn.execute("SELECT current_database() AS name").fetchone()["name"]
|
||||
ensure(actual == self.settings.db_name, "DATABASE_MISMATCH", "样板数据库不匹配", 503)
|
||||
ensure(actual == expected_database, "DATABASE_MISMATCH", "样板数据库不匹配", 503)
|
||||
yield conn
|
||||
except (psycopg.errors.UndefinedTable, psycopg.errors.InvalidSchemaName) as exc:
|
||||
raise SampleFailure("STORAGE_NOT_CONFIGURED", "样板存储表尚未配置", 503) from exc
|
||||
@@ -318,9 +334,9 @@ class PostgresStore:
|
||||
return document
|
||||
|
||||
def _work_order_event(self, conn, who: Identity, document: dict[str, Any], *, deleted: bool = False) -> None:
|
||||
# This rollout is restricted to the provisioned tjwater_v2 event log.
|
||||
# Task projections remain owned by the Server's authenticated APIs.
|
||||
if self.settings.db_name != "tjwater_v2":
|
||||
# Publish only into this project's provisioned event log. Never create
|
||||
# storage implicitly or write an event into another project's database.
|
||||
if not conn.execute("SELECT to_regclass('workbench.domain_event_log') AS event_log").fetchone()["event_log"]:
|
||||
return
|
||||
metadata = {
|
||||
"source": "peripheral_sample",
|
||||
@@ -529,8 +545,9 @@ class PostgresStore:
|
||||
if scenario == "missing_data":
|
||||
for item in items[::2]:
|
||||
item.update(value=None, quality="missing")
|
||||
source_database = conninfo_to_dict(self.routing.timescale_dsn)["dbname"] if self.routing else self.settings.db_name
|
||||
return {"origin": "synthetic" if scenario == "missing_data" else "historical_replay",
|
||||
"source_id": self.settings.db_name + ":scada.measurements", "source_revision": digest(items),
|
||||
"source_id": source_database + ":scada.measurements", "source_revision": digest(items),
|
||||
"items": items, "truncated": len(rows) > 1000}
|
||||
|
||||
def alerts(self, who: Identity) -> dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user