feat: add project-scoped operations
This commit is contained in:
@@ -7,9 +7,13 @@ from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.project_dependencies import get_metadata_repository
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
|
||||
from .auth import Identity, identity
|
||||
from .config import SampleSettings
|
||||
from .errors import SampleFailure
|
||||
from .routing import resolve_store_routing
|
||||
from .store import TRANSITIONS
|
||||
from .schemas import (
|
||||
AlertPage, Context, CreateTestDataRecord, CreateWorkOrder,
|
||||
@@ -20,9 +24,13 @@ from .schemas import (
|
||||
|
||||
|
||||
|
||||
def store(request: Request):
|
||||
async def store(request: Request, who: Identity = Depends(identity),
|
||||
repository: MetadataRepository = Depends(get_metadata_repository)):
|
||||
from .store import PostgresStore
|
||||
return PostgresStore(request.app.state.settings)
|
||||
routing = await resolve_store_routing(
|
||||
who, repository, timeseries=request.url.path.endswith("/monitoring/observations"),
|
||||
)
|
||||
return PostgresStore(request.app.state.settings, routing=routing)
|
||||
|
||||
|
||||
def create_app(settings: SampleSettings | None = None) -> FastAPI:
|
||||
|
||||
@@ -13,6 +13,7 @@ class Identity:
|
||||
project_id: str
|
||||
user_id: str
|
||||
can_write: bool
|
||||
project_code: str = ""
|
||||
|
||||
|
||||
def next_get(request: Request, path: str, params=None):
|
||||
@@ -42,8 +43,9 @@ def identity(request: Request) -> Identity:
|
||||
user_id = str(UUID(context["user_id"]))
|
||||
project_id = str(UUID(context["project_id"]))
|
||||
ensure(project_id == str(UUID(project)), "PROJECT_MISMATCH", "项目身份不一致", 403)
|
||||
ensure(context["network"] == request.app.state.settings.project_code,
|
||||
"PROJECT_NOT_CONFIGURED", "该项目不是本外围样板的数据项目", 403)
|
||||
project_code = context["network"]
|
||||
ensure(isinstance(project_code, str) and bool(project_code.strip()),
|
||||
"INVALID_AUTH_CONTEXT", "Next 项目身份不完整", 502)
|
||||
permissions = context["permissions"]
|
||||
ensure(isinstance(permissions, list) and "simulation.view" in permissions,
|
||||
"PERMISSION_DENIED", "没有样板读取权限", 403)
|
||||
@@ -51,4 +53,4 @@ def identity(request: Request) -> Identity:
|
||||
ensure(request.method == "GET" or can_write, "PERMISSION_DENIED", "没有样板修改权限", 403)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise SampleFailure("INVALID_AUTH_CONTEXT", "Next 身份响应不完整", 502) from exc
|
||||
return Identity(project_id, user_id, can_write)
|
||||
return Identity(project_id, user_id, can_write, project_code)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Resolve storage from authenticated project metadata, never browser input."""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from psycopg import ProgrammingError
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.auth.project_dependencies import (
|
||||
ProjectContext, resolve_project_business_routing, resolve_project_simulation_routing,
|
||||
)
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.infra.db.project_routing import ActiveProjectRouting
|
||||
|
||||
from .auth import Identity
|
||||
from .errors import SampleFailure, ensure
|
||||
|
||||
|
||||
async def resolve_store_routing(
|
||||
who: Identity, repository: MetadataRepository, *, timeseries: bool = False,
|
||||
) -> ActiveProjectRouting:
|
||||
context = ProjectContext(
|
||||
project_id=UUID(who.project_id), project_code=who.project_code,
|
||||
user_id=UUID(who.user_id), project_role="",
|
||||
)
|
||||
try:
|
||||
resolver = resolve_project_simulation_routing if timeseries else resolve_project_business_routing
|
||||
routing = await resolver(context, repository)
|
||||
dsns = [routing.business_dsn] + ([routing.timescale_dsn] if timeseries else [])
|
||||
ensure(all(dsn and conninfo_to_dict(dsn).get("dbname") for dsn in dsns),
|
||||
"PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库名称尚未配置", 503)
|
||||
return routing
|
||||
except (HTTPException, SQLAlchemyError, ValueError, ProgrammingError) as exc:
|
||||
raise SampleFailure(
|
||||
"PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库路由不可用或尚未配置", 503,
|
||||
) from exc
|
||||
+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