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
+10 -2
View File
@@ -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:
+5 -3
View File
@@ -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)
+36
View File
@@ -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
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]: