feat: add project-scoped operations
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent d320972d57
commit 1d061f6099
73 changed files with 4384 additions and 228 deletions
+27 -10
View File
@@ -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]: