feat: add governance backend APIs

This commit is contained in:
Codex
2026-05-02 22:06:03 +08:00
parent 9995a19b44
commit a736c66dc9
14 changed files with 2247 additions and 7 deletions
+280
View File
@@ -0,0 +1,280 @@
from typing import Optional
from fastapi import APIRouter, Depends, Form, Header
from sqlalchemy.orm import Session
from app.core.response import success_response
from app.db import get_db_session
from app.services.audit_log_service import AuditLogService
from app.services.auth_service import AuthService
from app.services.governance_service import GovernanceService
router = APIRouter(prefix="/governance", tags=["governance"])
ROUTE_PERMISSION_SCOPE = "governance_management"
ROUTE_PERMISSION_ACTIONS = {
"assets_list": "governance:assets:list",
"asset_detail": "governance:assets:detail",
"assignments_list": "governance:assignments:list",
"assignment_save": "governance:assignments:save",
"decision_submit": "governance:decisions:submit",
"evidence_save": "governance:evidences:save",
"release_summary": "governance:releases:summary",
"release_detail": "governance:releases:detail",
"formal_launch_import": "governance:imports:formal_launch",
}
def get_governance_service(db: Session = Depends(get_db_session)) -> GovernanceService:
return GovernanceService(db)
def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService:
return AuditLogService(db)
def require_current_user(
authorization: Optional[str] = Header(default=None),
db: Session = Depends(get_db_session),
):
return AuthService(db).require_current_user(authorization)
@router.get("/assets")
def list_governance_assets(
page: int = 1,
page_size: int = 20,
asset_type: Optional[str] = None,
governance_status: Optional[str] = None,
review_status: Optional[str] = None,
production_status: Optional[str] = None,
assignment_role: Optional[str] = None,
governance_service: GovernanceService = Depends(get_governance_service),
current_user: dict = Depends(require_current_user),
):
return success_response(
governance_service.list_assets(
current_user=current_user,
page=page,
page_size=page_size,
asset_type=asset_type,
governance_status=governance_status,
review_status=review_status,
production_status=production_status,
assignment_role=assignment_role,
)
)
@router.get("/assets/{governance_asset_id}")
def get_governance_asset_detail(
governance_asset_id: str,
governance_service: GovernanceService = Depends(get_governance_service),
current_user: dict = Depends(require_current_user),
):
return success_response(governance_service.get_asset_detail(current_user, governance_asset_id))
@router.get("/assignments")
def list_governance_assignments(
page: int = 1,
page_size: int = 20,
assignment_role: Optional[str] = None,
status_filter: Optional[str] = None,
assignee_user_id: Optional[str] = None,
governance_asset_id: Optional[str] = None,
governance_service: GovernanceService = Depends(get_governance_service),
current_user: dict = Depends(require_current_user),
):
return success_response(
governance_service.list_assignments(
current_user=current_user,
page=page,
page_size=page_size,
assignment_role=assignment_role,
status_filter=status_filter,
assignee_user_id=assignee_user_id,
governance_asset_id=governance_asset_id,
)
)
@router.post("/assignments")
def save_governance_assignment(
governance_asset_id: str = Form(...),
assignment_role: str = Form(...),
assignee_user_id: Optional[str] = Form(default=None),
assignee_name: Optional[str] = Form(default=None),
due_at: Optional[str] = Form(default=None),
status_value: Optional[str] = Form(default=None),
priority: Optional[str] = Form(default=None),
notes: Optional[str] = Form(default=None),
assignment_id: Optional[str] = Form(default=None),
is_active: bool = Form(default=True),
governance_service: GovernanceService = Depends(get_governance_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = governance_service.save_assignment(
current_user=current_user,
governance_asset_id=governance_asset_id,
assignment_role=assignment_role,
assignee_user_id=assignee_user_id,
assignee_name=assignee_name,
due_at=due_at,
status_value=status_value,
priority=priority,
notes=notes,
assignment_id=assignment_id,
is_active=is_active,
)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="save_governance_assignment",
resource_type="governance_assignment",
resource_id=result["id"],
detail_json={
"governance_asset_id": result["governance_asset_id"],
"assignment_role": result["assignment_role"],
"assignee_user_id": result["assignee_user_id"],
"assignee_name": result["assignee_name"],
"status": result["status"],
"priority": result["priority"],
"is_active": result["is_active"],
},
)
return success_response(result)
@router.post("/decisions")
def submit_governance_decision(
governance_asset_id: str = Form(...),
assignment_id: Optional[str] = Form(default=None),
decision_role: str = Form(...),
decision_result: str = Form(...),
decision_summary: Optional[str] = Form(default=None),
required_changes: Optional[str] = Form(default=None),
decided_by_name: Optional[str] = Form(default=None),
decision_at: Optional[str] = Form(default=None),
metadata_json: Optional[str] = Form(default=None),
governance_service: GovernanceService = Depends(get_governance_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = governance_service.submit_decision(
current_user=current_user,
governance_asset_id=governance_asset_id,
assignment_id=assignment_id,
decision_role=decision_role,
decision_result=decision_result,
decision_summary=decision_summary,
required_changes=required_changes,
decided_by_name=decided_by_name,
decision_at=decision_at,
metadata_json=metadata_json,
)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="submit_governance_decision",
resource_type="governance_decision",
resource_id=result["decision"]["id"],
detail_json={
"governance_asset_id": governance_asset_id,
"assignment_id": assignment_id,
"decision_role": result["decision"]["decision_role"],
"decision_result": result["decision"]["decision_result"],
},
)
return success_response(result)
@router.post("/evidences")
def save_governance_evidence(
governance_asset_id: str = Form(...),
decision_id: Optional[str] = Form(default=None),
file_id: Optional[str] = Form(default=None),
evidence_type: Optional[str] = Form(default=None),
storage_type: Optional[str] = Form(default=None),
external_url: Optional[str] = Form(default=None),
file_name: Optional[str] = Form(default=None),
checksum: Optional[str] = Form(default=None),
status_value: Optional[str] = Form(default=None),
reviewer_name: Optional[str] = Form(default=None),
collected_at: Optional[str] = Form(default=None),
metadata_json: Optional[str] = Form(default=None),
governance_service: GovernanceService = Depends(get_governance_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = governance_service.save_evidence(
current_user=current_user,
governance_asset_id=governance_asset_id,
decision_id=decision_id,
file_id=file_id,
evidence_type=evidence_type,
storage_type=storage_type,
external_url=external_url,
file_name=file_name,
checksum=checksum,
status_value=status_value,
reviewer_name=reviewer_name,
collected_at=collected_at,
metadata_json=metadata_json,
)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="save_governance_evidence",
resource_type="governance_evidence",
resource_id=result["evidence"]["id"],
detail_json={
"governance_asset_id": governance_asset_id,
"decision_id": decision_id,
"status": result["evidence"]["status"],
"storage_type": result["evidence"]["storage_type"],
"external_url": result["evidence"]["external_url"],
"file_id": result["evidence"]["file_id"],
},
)
return success_response(result)
@router.get("/releases/summary")
def get_governance_release_summary(
governance_service: GovernanceService = Depends(get_governance_service),
current_user: dict = Depends(require_current_user),
):
return success_response(governance_service.get_release_summary(current_user))
@router.get("/releases/{release_batch_id}")
def get_governance_release_detail(
release_batch_id: str,
governance_service: GovernanceService = Depends(get_governance_service),
current_user: dict = Depends(require_current_user),
):
return success_response(governance_service.get_release_detail(current_user, release_batch_id))
@router.post("/imports/formal-launch")
def import_formal_launch_governance(
batch_name: Optional[str] = Form(default=None),
governance_service: GovernanceService = Depends(get_governance_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = governance_service.import_formal_launch(current_user, batch_name=batch_name)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="import_formal_launch_governance",
resource_type="governance_import",
resource_id=result["release_batch"]["id"],
detail_json={
"batch_name": result["release_batch"]["batch_name"],
"status": result["release_batch"]["status"],
"imported": result["imported"],
},
)
return success_response(result)
+2
View File
@@ -8,6 +8,7 @@ from app.api.chat import router as chat_router
from app.api.contracts import router as contracts_router
from app.api.conversations import router as conversations_router
from app.api.files import router as files_router
from app.api.governance import router as governance_router
from app.api.knowledge_bases import router as knowledge_bases_router
from app.api.review_rules import router as review_rules_router
from app.api.reviews import router as reviews_router
@@ -34,6 +35,7 @@ app.include_router(chat_router, prefix=settings.api_v1_prefix)
app.include_router(contracts_router, prefix=settings.api_v1_prefix)
app.include_router(conversations_router, prefix=settings.api_v1_prefix)
app.include_router(files_router, prefix=settings.api_v1_prefix)
app.include_router(governance_router, prefix=settings.api_v1_prefix)
app.include_router(knowledge_bases_router, prefix=settings.api_v1_prefix)
app.include_router(review_rules_router, prefix=settings.api_v1_prefix)
app.include_router(templates_router, prefix=settings.api_v1_prefix)
@@ -0,0 +1,117 @@
from typing import Iterable, List, Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models.governance_asset import GovernanceAsset
class GovernanceAssetRepository:
def __init__(self, db: Session):
self.db = db
def create(self, asset: GovernanceAsset) -> GovernanceAsset:
self.db.add(asset)
self.db.commit()
self.db.refresh(asset)
return asset
def update(self, asset: GovernanceAsset) -> GovernanceAsset:
self.db.add(asset)
self.db.commit()
self.db.refresh(asset)
return asset
def get_by_id(self, asset_id: str, tenant_id: Optional[str] = None) -> Optional[GovernanceAsset]:
query = self.db.query(GovernanceAsset).filter(GovernanceAsset.id == asset_id)
if tenant_id is not None:
query = query.filter(GovernanceAsset.tenant_id == tenant_id)
return query.first()
def get_by_identity(self, tenant_id: str, asset_type: str, source_asset_id: str) -> Optional[GovernanceAsset]:
return (
self.db.query(GovernanceAsset)
.filter(
GovernanceAsset.tenant_id == tenant_id,
GovernanceAsset.asset_type == asset_type,
GovernanceAsset.asset_id == source_asset_id,
)
.first()
)
def list_paginated(
self,
tenant_id: str,
page: int,
page_size: int,
asset_ids: Optional[Iterable[str]] = None,
asset_type: Optional[str] = None,
governance_status: Optional[str] = None,
review_status: Optional[str] = None,
production_status: Optional[str] = None,
) -> List[GovernanceAsset]:
query = self.db.query(GovernanceAsset).filter(GovernanceAsset.tenant_id == tenant_id)
if asset_ids is not None:
normalized_asset_ids = [item for item in asset_ids if item]
if not normalized_asset_ids:
return []
query = query.filter(GovernanceAsset.id.in_(normalized_asset_ids))
if asset_type:
query = query.filter(GovernanceAsset.asset_type == asset_type)
if governance_status:
query = query.filter(GovernanceAsset.governance_status == governance_status)
if review_status:
query = query.filter(GovernanceAsset.review_status == review_status)
if production_status:
query = query.filter(GovernanceAsset.production_status == production_status)
return (
query.order_by(GovernanceAsset.updated_at.desc(), GovernanceAsset.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
def count(
self,
tenant_id: str,
asset_ids: Optional[Iterable[str]] = None,
asset_type: Optional[str] = None,
governance_status: Optional[str] = None,
review_status: Optional[str] = None,
production_status: Optional[str] = None,
) -> int:
query = self.db.query(GovernanceAsset).filter(GovernanceAsset.tenant_id == tenant_id)
if asset_ids is not None:
normalized_asset_ids = [item for item in asset_ids if item]
if not normalized_asset_ids:
return 0
query = query.filter(GovernanceAsset.id.in_(normalized_asset_ids))
if asset_type:
query = query.filter(GovernanceAsset.asset_type == asset_type)
if governance_status:
query = query.filter(GovernanceAsset.governance_status == governance_status)
if review_status:
query = query.filter(GovernanceAsset.review_status == review_status)
if production_status:
query = query.filter(GovernanceAsset.production_status == production_status)
return query.count()
def count_grouped_by_status(self, tenant_id: str, column_name: str) -> dict[str, int]:
column = getattr(GovernanceAsset, column_name)
rows = (
self.db.query(column, func.count(GovernanceAsset.id))
.filter(GovernanceAsset.tenant_id == tenant_id)
.group_by(column)
.all()
)
return {status or "unknown": count for status, count in rows}
def list_by_ids(self, tenant_id: str, governance_asset_ids: Iterable[str]) -> List[GovernanceAsset]:
asset_ids = [item for item in governance_asset_ids if item]
if not asset_ids:
return []
return (
self.db.query(GovernanceAsset)
.filter(GovernanceAsset.tenant_id == tenant_id, GovernanceAsset.id.in_(asset_ids))
.all()
)
@@ -0,0 +1,67 @@
from typing import Iterable, List, Optional
from sqlalchemy.orm import Session
from app.models.governance_evidence import GovernanceEvidence
class GovernanceEvidenceRepository:
def __init__(self, db: Session):
self.db = db
def create(self, evidence: GovernanceEvidence) -> GovernanceEvidence:
self.db.add(evidence)
self.db.commit()
self.db.refresh(evidence)
return evidence
def update(self, evidence: GovernanceEvidence) -> GovernanceEvidence:
self.db.add(evidence)
self.db.commit()
self.db.refresh(evidence)
return evidence
def get_by_id(self, evidence_id: str, tenant_id: Optional[str] = None) -> Optional[GovernanceEvidence]:
query = self.db.query(GovernanceEvidence).filter(GovernanceEvidence.id == evidence_id)
if tenant_id is not None:
query = query.filter(GovernanceEvidence.tenant_id == tenant_id)
return query.first()
def get_latest_by_asset(self, tenant_id: str, governance_asset_id: str) -> Optional[GovernanceEvidence]:
return (
self.db.query(GovernanceEvidence)
.filter(
GovernanceEvidence.tenant_id == tenant_id,
GovernanceEvidence.governance_asset_id == governance_asset_id,
)
.order_by(GovernanceEvidence.collected_at.desc().nullslast(), GovernanceEvidence.created_at.desc())
.first()
)
def list_by_asset_id(self, tenant_id: str, governance_asset_id: str) -> List[GovernanceEvidence]:
return (
self.db.query(GovernanceEvidence)
.filter(
GovernanceEvidence.tenant_id == tenant_id,
GovernanceEvidence.governance_asset_id == governance_asset_id,
)
.order_by(GovernanceEvidence.collected_at.desc().nullslast(), GovernanceEvidence.created_at.desc())
.all()
)
def count_by_asset_ids(self, tenant_id: str, governance_asset_ids: Iterable[str]) -> dict[str, int]:
asset_ids = [item for item in governance_asset_ids if item]
if not asset_ids:
return {}
rows = (
self.db.query(GovernanceEvidence.governance_asset_id, GovernanceEvidence.id)
.filter(
GovernanceEvidence.tenant_id == tenant_id,
GovernanceEvidence.governance_asset_id.in_(asset_ids),
)
.all()
)
counts: dict[str, int] = {}
for governance_asset_id, _ in rows:
counts[governance_asset_id] = counts.get(governance_asset_id, 0) + 1
return counts
@@ -0,0 +1,27 @@
from typing import List
from sqlalchemy.orm import Session
from app.models.governance_gate_run import GovernanceGateRun
class GovernanceGateRunRepository:
def __init__(self, db: Session):
self.db = db
def create(self, gate_run: GovernanceGateRun) -> GovernanceGateRun:
self.db.add(gate_run)
self.db.commit()
self.db.refresh(gate_run)
return gate_run
def list_by_release_batch_id(self, tenant_id: str, release_batch_id: str) -> List[GovernanceGateRun]:
return (
self.db.query(GovernanceGateRun)
.filter(
GovernanceGateRun.tenant_id == tenant_id,
GovernanceGateRun.release_batch_id == release_batch_id,
)
.order_by(GovernanceGateRun.executed_at.desc(), GovernanceGateRun.created_at.desc())
.all()
)
@@ -0,0 +1,37 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.governance_release_batch import GovernanceReleaseBatch
class GovernanceReleaseBatchRepository:
def __init__(self, db: Session):
self.db = db
def create(self, release_batch: GovernanceReleaseBatch) -> GovernanceReleaseBatch:
self.db.add(release_batch)
self.db.commit()
self.db.refresh(release_batch)
return release_batch
def update(self, release_batch: GovernanceReleaseBatch) -> GovernanceReleaseBatch:
self.db.add(release_batch)
self.db.commit()
self.db.refresh(release_batch)
return release_batch
def get_by_id(self, release_batch_id: str, tenant_id: Optional[str] = None) -> Optional[GovernanceReleaseBatch]:
query = self.db.query(GovernanceReleaseBatch).filter(GovernanceReleaseBatch.id == release_batch_id)
if tenant_id is not None:
query = query.filter(GovernanceReleaseBatch.tenant_id == tenant_id)
return query.first()
def list_recent(self, tenant_id: str, limit: int = 10) -> List[GovernanceReleaseBatch]:
return (
self.db.query(GovernanceReleaseBatch)
.filter(GovernanceReleaseBatch.tenant_id == tenant_id)
.order_by(GovernanceReleaseBatch.started_at.desc(), GovernanceReleaseBatch.created_at.desc())
.limit(limit)
.all()
)
@@ -0,0 +1,122 @@
from typing import Iterable, List, Optional
from sqlalchemy.orm import Session
from app.models.governance_review_assignment import GovernanceReviewAssignment
class GovernanceReviewAssignmentRepository:
def __init__(self, db: Session):
self.db = db
def create(self, assignment: GovernanceReviewAssignment) -> GovernanceReviewAssignment:
self.db.add(assignment)
self.db.commit()
self.db.refresh(assignment)
return assignment
def update(self, assignment: GovernanceReviewAssignment) -> GovernanceReviewAssignment:
self.db.add(assignment)
self.db.commit()
self.db.refresh(assignment)
return assignment
def get_by_id(self, assignment_id: str, tenant_id: Optional[str] = None) -> Optional[GovernanceReviewAssignment]:
query = self.db.query(GovernanceReviewAssignment).filter(GovernanceReviewAssignment.id == assignment_id)
if tenant_id is not None:
query = query.filter(GovernanceReviewAssignment.tenant_id == tenant_id)
return query.first()
def get_active_by_asset_role(self, tenant_id: str, governance_asset_id: str, assignment_role: str) -> Optional[GovernanceReviewAssignment]:
return (
self.db.query(GovernanceReviewAssignment)
.filter(
GovernanceReviewAssignment.tenant_id == tenant_id,
GovernanceReviewAssignment.governance_asset_id == governance_asset_id,
GovernanceReviewAssignment.assignment_role == assignment_role,
GovernanceReviewAssignment.is_active.is_(True),
)
.order_by(GovernanceReviewAssignment.updated_at.desc(), GovernanceReviewAssignment.created_at.desc())
.first()
)
def list_paginated(
self,
tenant_id: str,
page: int,
page_size: int,
assignment_role: Optional[str] = None,
status: Optional[str] = None,
assignee_user_id: Optional[str] = None,
governance_asset_id: Optional[str] = None,
) -> List[GovernanceReviewAssignment]:
query = self.db.query(GovernanceReviewAssignment).filter(GovernanceReviewAssignment.tenant_id == tenant_id)
if assignment_role:
query = query.filter(GovernanceReviewAssignment.assignment_role == assignment_role)
if status:
query = query.filter(GovernanceReviewAssignment.status == status)
if assignee_user_id:
query = query.filter(GovernanceReviewAssignment.assignee_user_id == assignee_user_id)
if governance_asset_id:
query = query.filter(GovernanceReviewAssignment.governance_asset_id == governance_asset_id)
return (
query.order_by(GovernanceReviewAssignment.updated_at.desc(), GovernanceReviewAssignment.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
def count(
self,
tenant_id: str,
assignment_role: Optional[str] = None,
status: Optional[str] = None,
assignee_user_id: Optional[str] = None,
governance_asset_id: Optional[str] = None,
) -> int:
query = self.db.query(GovernanceReviewAssignment).filter(GovernanceReviewAssignment.tenant_id == tenant_id)
if assignment_role:
query = query.filter(GovernanceReviewAssignment.assignment_role == assignment_role)
if status:
query = query.filter(GovernanceReviewAssignment.status == status)
if assignee_user_id:
query = query.filter(GovernanceReviewAssignment.assignee_user_id == assignee_user_id)
if governance_asset_id:
query = query.filter(GovernanceReviewAssignment.governance_asset_id == governance_asset_id)
return query.count()
def list_active_by_asset_ids(self, tenant_id: str, governance_asset_ids: Iterable[str]) -> List[GovernanceReviewAssignment]:
asset_ids = [item for item in governance_asset_ids if item]
if not asset_ids:
return []
return (
self.db.query(GovernanceReviewAssignment)
.filter(
GovernanceReviewAssignment.tenant_id == tenant_id,
GovernanceReviewAssignment.governance_asset_id.in_(asset_ids),
GovernanceReviewAssignment.is_active.is_(True),
)
.all()
)
def list_active_by_role(self, tenant_id: str, assignment_role: str) -> List[GovernanceReviewAssignment]:
return (
self.db.query(GovernanceReviewAssignment)
.filter(
GovernanceReviewAssignment.tenant_id == tenant_id,
GovernanceReviewAssignment.assignment_role == assignment_role,
GovernanceReviewAssignment.is_active.is_(True),
)
.all()
)
def list_by_asset_id(self, tenant_id: str, governance_asset_id: str) -> List[GovernanceReviewAssignment]:
return (
self.db.query(GovernanceReviewAssignment)
.filter(
GovernanceReviewAssignment.tenant_id == tenant_id,
GovernanceReviewAssignment.governance_asset_id == governance_asset_id,
)
.order_by(GovernanceReviewAssignment.updated_at.desc(), GovernanceReviewAssignment.created_at.desc())
.all()
)
@@ -0,0 +1,56 @@
from typing import Iterable, List, Optional
from sqlalchemy.orm import Session
from app.models.governance_review_decision import GovernanceReviewDecision
class GovernanceReviewDecisionRepository:
def __init__(self, db: Session):
self.db = db
def create(self, decision: GovernanceReviewDecision) -> GovernanceReviewDecision:
self.db.add(decision)
self.db.commit()
self.db.refresh(decision)
return decision
def get_latest_by_asset_role(self, tenant_id: str, governance_asset_id: str, decision_role: str) -> Optional[GovernanceReviewDecision]:
return (
self.db.query(GovernanceReviewDecision)
.filter(
GovernanceReviewDecision.tenant_id == tenant_id,
GovernanceReviewDecision.governance_asset_id == governance_asset_id,
GovernanceReviewDecision.decision_role == decision_role,
)
.order_by(GovernanceReviewDecision.decision_at.desc().nullslast(), GovernanceReviewDecision.created_at.desc())
.first()
)
def list_by_asset_id(self, tenant_id: str, governance_asset_id: str) -> List[GovernanceReviewDecision]:
return (
self.db.query(GovernanceReviewDecision)
.filter(
GovernanceReviewDecision.tenant_id == tenant_id,
GovernanceReviewDecision.governance_asset_id == governance_asset_id,
)
.order_by(GovernanceReviewDecision.decision_at.desc().nullslast(), GovernanceReviewDecision.created_at.desc())
.all()
)
def count_by_asset_ids(self, tenant_id: str, governance_asset_ids: Iterable[str]) -> dict[str, int]:
asset_ids = [item for item in governance_asset_ids if item]
if not asset_ids:
return {}
rows = (
self.db.query(GovernanceReviewDecision.governance_asset_id, GovernanceReviewDecision.id)
.filter(
GovernanceReviewDecision.tenant_id == tenant_id,
GovernanceReviewDecision.governance_asset_id.in_(asset_ids),
)
.all()
)
counts: dict[str, int] = {}
for governance_asset_id, _ in rows:
counts[governance_asset_id] = counts.get(governance_asset_id, 0) + 1
return counts
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
import unittest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.api.governance import get_audit_log_service, get_governance_service, require_current_user
from app.main import app
class StubAuditLogService:
def __init__(self):
self.events = []
def record_event(self, **kwargs):
self.events.append(kwargs)
return kwargs
class StubGovernanceService:
def list_assets(self, current_user, page, page_size, asset_type, governance_status, review_status, production_status, assignment_role):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"items": [
{
"id": "govast-123",
"tenant_id": current_user["tenant_id"],
"asset_type": asset_type or "contract_template",
"asset_id": "TPL-SALES-GOODS-001",
"asset_name": "销售合同模板",
"source_table": "formal_launch_template_signoff",
"source_path": "project-docs/formal-launch-ops/signoff/production-template-signoff-register.csv",
"governance_status": governance_status or "reviewing",
"review_status": review_status or "pending",
"production_status": production_status or "blocked",
"latest_decision_result": None,
"latest_evidence_status": "template",
"metadata": {"contract_type": "sales"},
"notes": None,
"active_assignment_roles": [assignment_role or "legal_reviewer"],
"decision_count": 0,
"evidence_count": 1,
"created_at": "2026-05-02T00:00:00+00:00",
"updated_at": "2026-05-02T00:00:00+00:00",
}
],
"page": page,
"page_size": page_size,
"total": 1,
"summary": {
"total_assets": 1,
"governance_status": {"reviewing": 1},
"review_status": {"pending": 1},
"production_status": {"blocked": 1},
"pending_assignments": 1,
"in_review_assignments": 0,
"blocking_items": [{"type": "assignment", "status": "pending", "count": 1}],
},
}
def get_asset_detail(self, current_user, governance_asset_id):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"asset": {
"id": governance_asset_id,
"asset_name": "销售合同模板",
},
"assignments": [{"id": "govasg-123"}],
"decisions": [],
"evidences": [{"id": "govev-123"}],
}
def list_assignments(self, current_user, page, page_size, assignment_role, status_filter, assignee_user_id, governance_asset_id):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"items": [
{
"id": "govasg-123",
"tenant_id": current_user["tenant_id"],
"governance_asset_id": governance_asset_id or "govast-123",
"assignment_role": assignment_role or "legal_reviewer",
"assignee_user_id": assignee_user_id,
"assignee_name": "法务负责人A",
"due_at": "2026-05-03T00:00:00+00:00",
"status": status_filter or "pending",
"priority": "high",
"is_active": True,
"notes": None,
"created_at": "2026-05-02T00:00:00+00:00",
"updated_at": "2026-05-02T00:00:00+00:00",
"asset_name": "销售合同模板",
"asset_type": "contract_template",
}
],
"page": page,
"page_size": page_size,
"total": 1,
}
def save_assignment(self, current_user, governance_asset_id, assignment_role, assignee_user_id, assignee_name, due_at, status_value, priority, notes, assignment_id=None, is_active=True):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"id": assignment_id or "govasg-123",
"tenant_id": current_user["tenant_id"],
"governance_asset_id": governance_asset_id,
"assignment_role": assignment_role,
"assignee_user_id": assignee_user_id,
"assignee_name": assignee_name,
"due_at": due_at,
"status": status_value or "pending",
"priority": priority or "normal",
"is_active": is_active,
"notes": notes,
"created_at": "2026-05-02T00:00:00+00:00",
"updated_at": "2026-05-02T00:00:00+00:00",
"asset_name": "销售合同模板",
"asset_type": "contract_template",
}
def submit_decision(self, current_user, governance_asset_id, assignment_id, decision_role, decision_result, decision_summary, required_changes, decided_by_name, decision_at, metadata_json):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"decision": {
"id": "govdec-123",
"governance_asset_id": governance_asset_id,
"assignment_id": assignment_id,
"decision_role": decision_role,
"decision_result": decision_result,
},
"asset": {
"id": governance_asset_id,
"review_status": "approved" if decision_result == "approved" else "blocked",
},
}
def save_evidence(self, current_user, governance_asset_id, decision_id, file_id, evidence_type, storage_type, external_url, file_name, checksum, status_value, reviewer_name, collected_at, metadata_json):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"evidence": {
"id": "govev-123",
"governance_asset_id": governance_asset_id,
"decision_id": decision_id,
"file_id": file_id,
"storage_type": storage_type or "external",
"external_url": external_url,
"status": status_value or "ready",
},
"asset": {
"id": governance_asset_id,
"latest_evidence_status": status_value or "ready",
},
}
def get_release_summary(self, current_user):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"summary": {
"total_assets": 10,
"blocking_items": [{"type": "assignment", "status": "pending", "count": 4}],
},
"recent_batches": [
{
"id": "govrel-123",
"batch_name": "formal-launch-import",
"status": "blocked",
}
],
}
def get_release_detail(self, current_user, release_batch_id):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"release_batch": {"id": release_batch_id, "status": "blocked"},
"gate_runs": [{"id": "govgate-123", "gate_name": "formal_launch_import"}],
}
def import_formal_launch(self, current_user, batch_name=None):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"imported": {
"assets": 10,
"assignments": 12,
"decisions": 0,
"evidences": 10,
},
"summary": {"total_assets": 10},
"release_batch": {"id": "govrel-123", "batch_name": batch_name or "formal-launch-import", "status": "blocked"},
"gate_run": {"id": "govgate-123", "status": "blocked"},
}
class GovernanceApiTestCase(unittest.TestCase):
def setUp(self):
self.audit_log_service = StubAuditLogService()
app.dependency_overrides[get_governance_service] = lambda: StubGovernanceService()
app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service
self.client = TestClient(app)
def tearDown(self):
app.dependency_overrides.clear()
def _set_admin(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
"display_name": "管理员",
}
def test_admin_can_list_governance_assets(self):
self._set_admin()
response = self.client.get("/api/v1/governance/assets", params={"asset_type": "contract_template"})
self.assertEqual(response.status_code, 200)
body = response.json()["data"]
self.assertEqual(body["items"][0]["asset_name"], "销售合同模板")
self.assertEqual(body["summary"]["total_assets"], 1)
def test_admin_can_list_governance_assignments(self):
self._set_admin()
response = self.client.get("/api/v1/governance/assignments", params={"assignment_role": "legal_reviewer"})
self.assertEqual(response.status_code, 200)
body = response.json()["data"]
self.assertEqual(body["items"][0]["assignment_role"], "legal_reviewer")
self.assertEqual(body["items"][0]["asset_name"], "销售合同模板")
def test_admin_can_save_assignment(self):
self._set_admin()
response = self.client.post(
"/api/v1/governance/assignments",
data={
"governance_asset_id": "govast-123",
"assignment_role": "legal_reviewer",
"assignee_name": "法务负责人A",
"status_value": "pending",
"priority": "high",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["assignment_role"], "legal_reviewer")
self.assertEqual(self.audit_log_service.events[0]["action"], "save_governance_assignment")
def test_admin_can_submit_decision(self):
self._set_admin()
response = self.client.post(
"/api/v1/governance/decisions",
data={
"governance_asset_id": "govast-123",
"assignment_id": "govasg-123",
"decision_role": "legal_reviewer",
"decision_result": "approved",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["decision"]["decision_result"], "approved")
self.assertEqual(self.audit_log_service.events[0]["action"], "submit_governance_decision")
def test_admin_can_save_evidence(self):
self._set_admin()
response = self.client.post(
"/api/v1/governance/evidences",
data={
"governance_asset_id": "govast-123",
"external_url": "https://example.com/evidence/1",
"status_value": "ready",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["evidence"]["status"], "ready")
self.assertEqual(self.audit_log_service.events[0]["action"], "save_governance_evidence")
def test_admin_can_get_release_summary_and_import(self):
self._set_admin()
summary_response = self.client.get("/api/v1/governance/releases/summary")
import_response = self.client.post("/api/v1/governance/imports/formal-launch", data={"batch_name": "formal-launch-import"})
self.assertEqual(summary_response.status_code, 200)
self.assertEqual(summary_response.json()["data"]["recent_batches"][0]["status"], "blocked")
self.assertEqual(import_response.status_code, 200)
self.assertEqual(import_response.json()["data"]["release_batch"]["batch_name"], "formal-launch-import")
self.assertEqual(self.audit_log_service.events[-1]["action"], "import_formal_launch_governance")
if __name__ == "__main__":
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db import Base
from app.services.governance_service import GovernanceService
import app.models # noqa: F401
class GovernanceServiceTestCase(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
TestingSessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, future=True)
Base.metadata.create_all(self.engine)
self.db = TestingSessionLocal()
self.service = GovernanceService(self.db)
self.admin_user = {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
"display_name": "管理员",
"username": "admin",
}
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_assignment_decision_and_evidence_refresh_asset_status(self):
asset, _ = self.service._upsert_governance_asset(
tenant_id=self.admin_user["tenant_id"],
asset_type="contract_template",
asset_id="TPL-DEMO-001",
asset_name="演示模板",
source_path=None,
source_table="manual",
metadata={},
)
assignment = self.service.save_assignment(
current_user=self.admin_user,
governance_asset_id=asset.id,
assignment_role="legal_reviewer",
assignee_user_id=None,
assignee_name="法务A",
due_at="2026-05-03",
status_value="pending",
priority="high",
notes=None,
)
self.assertEqual(assignment["status"], "pending")
self.service.save_evidence(
current_user=self.admin_user,
governance_asset_id=asset.id,
decision_id=None,
file_id=None,
evidence_type="document",
storage_type="external",
external_url="https://example.com/evidence/ready",
file_name="evidence.md",
checksum=None,
status_value="ready",
reviewer_name="法务A",
collected_at="2026-05-02",
metadata_json=None,
)
decision_result = self.service.submit_decision(
current_user=self.admin_user,
governance_asset_id=asset.id,
assignment_id=assignment["id"],
decision_role="legal_reviewer",
decision_result="approved",
decision_summary="通过",
required_changes=None,
decided_by_name="法务A",
decision_at="2026-05-02",
metadata_json=None,
)
self.assertEqual(decision_result["asset"]["review_status"], "approved")
self.assertEqual(decision_result["asset"]["production_status"], "ready")
def test_import_formal_launch_creates_assets_and_release_batch(self):
result = self.service.import_formal_launch(self.admin_user, batch_name="formal-launch-import")
self.assertGreater(result["imported"]["assets"], 0)
self.assertGreater(result["summary"]["total_assets"], 0)
self.assertEqual(result["release_batch"]["batch_name"], "formal-launch-import")
self.assertIn(result["release_batch"]["status"], {"blocked", "in_review", "approved"})
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -1,6 +1,6 @@
import unittest
from app.api import audit_logs, auth, chat, contracts, conversations, files, knowledge_bases, review_rules, reviews, system, templates, tenant_settings, users
from app.api import audit_logs, auth, chat, contracts, conversations, files, governance, knowledge_bases, review_rules, reviews, system, templates, tenant_settings, users
class PermissionMetadataTestCase(unittest.TestCase):
@@ -12,6 +12,7 @@ class PermissionMetadataTestCase(unittest.TestCase):
contracts,
conversations,
files,
governance,
knowledge_bases,
review_rules,
reviews,
@@ -35,6 +36,7 @@ class PermissionMetadataTestCase(unittest.TestCase):
contracts,
conversations,
files,
governance,
knowledge_bases,
review_rules,
reviews,
@@ -6,12 +6,12 @@ batch,priority,area,task_id,title,current_gap,target_deliverable,depends_on,acce
5A,P0,database,GOV-DB-005,新增发布批次与门禁结果表,当前上线批准状态只存在 PowerShell 产物中,新增 governance_release_batches 和 governance_gate_runs 表,GOV-DB-001;GOV-DB-003;GOV-DB-004,系统可追踪一次发布批次的门禁快照和结果,COMPLETED
5A,P1,database,GOV-DB-006,模板表补治理字段,contract_templates 只有内容和启停没有版本与发布元数据,为 contract_templates 增加 template_code version published_at published_by latest_governance_asset_id,GOV-DB-001,模板可区分草稿版和生产发布版,PLANNED
5A,P1,database,GOV-DB-007,知识库文档表补法源元数据,知识库文档缺少效力级别生效日期发文机关和法源类型,为知识库相关表增加 source_type authority effective_date region tags,none,知识库可按法源维度治理和过滤,PLANNED
5B,P0,backend_api,GOV-API-001,新增治理资产列表接口,当前没有统一治理视图接口,新增 /governance/assets 列表筛选汇总接口,GOV-DB-001,前端可按资产类型状态角色查看治理资产,PLANNED
5B,P0,backend_api,GOV-API-002,新增复核分配接口,当前无法在系统内分配真实复核人,新增 /governance/assignments 创建更新撤销接口,GOV-DB-002,管理员可在系统内完成复核任务分配,PLANNED
5B,P0,backend_api,GOV-API-003,新增复核回填接口,当前复核结论只能通过CSV和脚本回填,新增 /governance/decisions 提交接口和批量导入接口,GOV-DB-003,真实复核人或管理员可提交复核结果,PLANNED
5B,P0,backend_api,GOV-API-004,新增证据上传绑定接口,当前系统没有专门的治理证据绑定接口,新增 /governance/evidences 上传绑定替换校验接口,GOV-DB-004,系统可阻止模板证据进入已批准状态,PLANNED
5B,P0,backend_api,GOV-API-005,新增上线批准汇总接口,当前没有系统内 release approval summary,新增 /governance/releases/summary 和 /governance/releases/{id} 接口,GOV-DB-005,前端可直接展示当前上线阻断项与通过项,PLANNED
5B,P0,backend_api,GOV-API-006,新增CSV台账导入接口,当前正式上线台账只靠仓库脚本导入,新增 /governance/imports/formal-launch 接口读取既有 CSV 映射到治理表,GOV-DB-001;GOV-DB-002;GOV-DB-003;GOV-DB-004,旧台账可一次性导入数据库且保留来源信息,PLANNED
5B,P0,backend_api,GOV-API-001,新增治理资产列表接口,当前没有统一治理视图接口,新增 /governance/assets 列表筛选汇总接口,GOV-DB-001,前端可按资产类型状态角色查看治理资产,COMPLETED
5B,P0,backend_api,GOV-API-002,新增复核分配接口,当前无法在系统内分配真实复核人,新增 /governance/assignments 创建更新撤销接口,GOV-DB-002,管理员可在系统内完成复核任务分配,COMPLETED
5B,P0,backend_api,GOV-API-003,新增复核回填接口,当前复核结论只能通过CSV和脚本回填,新增 /governance/decisions 提交接口和批量导入接口,GOV-DB-003,真实复核人或管理员可提交复核结果,COMPLETED
5B,P0,backend_api,GOV-API-004,新增证据上传绑定接口,当前系统没有专门的治理证据绑定接口,新增 /governance/evidences 上传绑定替换校验接口,GOV-DB-004,系统可阻止模板证据进入已批准状态,COMPLETED
5B,P0,backend_api,GOV-API-005,新增上线批准汇总接口,当前没有系统内 release approval summary,新增 /governance/releases/summary 和 /governance/releases/{id} 接口,GOV-DB-005,前端可直接展示当前上线阻断项与通过项,COMPLETED
5B,P0,backend_api,GOV-API-006,新增CSV台账导入接口,当前正式上线台账只靠仓库脚本导入,新增 /governance/imports/formal-launch 接口读取既有 CSV 映射到治理表,GOV-DB-001;GOV-DB-002;GOV-DB-003;GOV-DB-004,旧台账可一次性导入数据库且保留来源信息,COMPLETED
5B,P1,backend_api,GOV-API-007,模板规则知识库发布接口,当前内容模块没有生产发布动作,为 templates review-rules knowledge-bases 增加 publish/unpublish/rollback 接口,GOV-DB-005;GOV-DB-006,已批准资产才能被发布到生产可用集合,PLANNED
5C,P0,frontend,GOV-WEB-001,新增复核总控中心页面,当前没有统一治理控制台,新增 /governance 页面展示资产统计阻断项角色视图和发布批次,GOV-API-001;GOV-API-005,管理员可在单页看到当前正式上线治理全貌,PLANNED
5C,P0,frontend,GOV-WEB-002,新增复核人工作台页面,当前真实法务业务运营没有系统内待办页,新增 /governance/workbench 页面支持按角色查看待处理任务,GOV-API-001;GOV-API-002;GOV-API-003,复核人可只看到与自己相关的任务包,PLANNED
1 batch priority area task_id title current_gap target_deliverable depends_on acceptance status
6 5A P0 database GOV-DB-005 新增发布批次与门禁结果表 当前上线批准状态只存在 PowerShell 产物中 新增 governance_release_batches 和 governance_gate_runs 表 GOV-DB-001;GOV-DB-003;GOV-DB-004 系统可追踪一次发布批次的门禁快照和结果 COMPLETED
7 5A P1 database GOV-DB-006 模板表补治理字段 contract_templates 只有内容和启停没有版本与发布元数据 为 contract_templates 增加 template_code version published_at published_by latest_governance_asset_id GOV-DB-001 模板可区分草稿版和生产发布版 PLANNED
8 5A P1 database GOV-DB-007 知识库文档表补法源元数据 知识库文档缺少效力级别生效日期发文机关和法源类型 为知识库相关表增加 source_type authority effective_date region tags none 知识库可按法源维度治理和过滤 PLANNED
9 5B P0 backend_api GOV-API-001 新增治理资产列表接口 当前没有统一治理视图接口 新增 /governance/assets 列表筛选汇总接口 GOV-DB-001 前端可按资产类型状态角色查看治理资产 PLANNED COMPLETED
10 5B P0 backend_api GOV-API-002 新增复核分配接口 当前无法在系统内分配真实复核人 新增 /governance/assignments 创建更新撤销接口 GOV-DB-002 管理员可在系统内完成复核任务分配 PLANNED COMPLETED
11 5B P0 backend_api GOV-API-003 新增复核回填接口 当前复核结论只能通过CSV和脚本回填 新增 /governance/decisions 提交接口和批量导入接口 GOV-DB-003 真实复核人或管理员可提交复核结果 PLANNED COMPLETED
12 5B P0 backend_api GOV-API-004 新增证据上传绑定接口 当前系统没有专门的治理证据绑定接口 新增 /governance/evidences 上传绑定替换校验接口 GOV-DB-004 系统可阻止模板证据进入已批准状态 PLANNED COMPLETED
13 5B P0 backend_api GOV-API-005 新增上线批准汇总接口 当前没有系统内 release approval summary 新增 /governance/releases/summary 和 /governance/releases/{id} 接口 GOV-DB-005 前端可直接展示当前上线阻断项与通过项 PLANNED COMPLETED
14 5B P0 backend_api GOV-API-006 新增CSV台账导入接口 当前正式上线台账只靠仓库脚本导入 新增 /governance/imports/formal-launch 接口读取既有 CSV 映射到治理表 GOV-DB-001;GOV-DB-002;GOV-DB-003;GOV-DB-004 旧台账可一次性导入数据库且保留来源信息 PLANNED COMPLETED
15 5B P1 backend_api GOV-API-007 模板规则知识库发布接口 当前内容模块没有生产发布动作 为 templates review-rules knowledge-bases 增加 publish/unpublish/rollback 接口 GOV-DB-005;GOV-DB-006 已批准资产才能被发布到生产可用集合 PLANNED
16 5C P0 frontend GOV-WEB-001 新增复核总控中心页面 当前没有统一治理控制台 新增 /governance 页面展示资产统计阻断项角色视图和发布批次 GOV-API-001;GOV-API-005 管理员可在单页看到当前正式上线治理全貌 PLANNED
17 5C P0 frontend GOV-WEB-002 新增复核人工作台页面 当前真实法务业务运营没有系统内待办页 新增 /governance/workbench 页面支持按角色查看待处理任务 GOV-API-001;GOV-API-002;GOV-API-003 复核人可只看到与自己相关的任务包 PLANNED
@@ -0,0 +1,120 @@
# 正式上线运营准备阶段 5B 后端治理接口执行记录(2026-05-02)
## 本批目标
本批执行 `5B / GOV-API-001 ~ GOV-API-006`,目标是在已完成的治理域数据库骨架上,补齐系统内的治理接口,让“治理资产、复核任务、复核结论、证据、上线批准汇总、CSV 导入”不再只存在于仓库脚本中。
## 已完成接口
新增路由文件:
- `backend/app/api/governance.py`
并已注册到:
- `backend/app/main.py`
本批已完成接口:
1. `GET /api/v1/governance/assets`
支持治理资产列表、状态筛选和汇总摘要。
2. `GET /api/v1/governance/assets/{governance_asset_id}`
返回单资产详情、分配、决议和证据。
3. `GET /api/v1/governance/assignments`
返回复核分配列表。
4. `POST /api/v1/governance/assignments`
创建或更新复核分配。
5. `POST /api/v1/governance/decisions`
提交复核结论,并回写资产治理状态。
6. `POST /api/v1/governance/evidences`
绑定证据,并回写资产证据状态。
7. `GET /api/v1/governance/releases/summary`
返回当前治理汇总和最近发布批次。
8. `GET /api/v1/governance/releases/{release_batch_id}`
返回指定发布批次和门禁执行记录。
9. `POST /api/v1/governance/imports/formal-launch`
读取现有 formal-launch CSV 台账并导入数据库。
## 已完成仓储与服务
新增仓储:
- `governance_asset_repository.py`
- `governance_review_assignment_repository.py`
- `governance_review_decision_repository.py`
- `governance_evidence_repository.py`
- `governance_release_batch_repository.py`
- `governance_gate_run_repository.py`
新增服务:
- `backend/app/services/governance_service.py`
### 服务能力
`GovernanceService` 已实现:
- 治理资产分页查询。
- 单资产详情聚合。
- 复核分配查询与保存。
- 复核结论提交。
- 证据登记。
- 发布汇总查询。
- formal-launch 台账导入。
## formal-launch 导入能力
导入接口会读取以下已有台账:
- `real-review-task-package.csv`
- `production-material-signoff-register.csv`
- `production-template-signoff-register.csv`
- `production-knowledge-signoff-register.csv`
导入后会自动:
1. 建立治理资产。
2. 建立复核分配。
3. 导入已存在的决议状态。
4. 读取证据路径并判定 `template / ready / missing`。
5. 生成一条发布批次。
6. 生成一条门禁快照记录。
## 已完成验证
执行测试:
`backend/.venv/Scripts/python.exe -m unittest tests.test_governance_models tests.test_governance_api tests.test_governance_service tests.test_permission_metadata`
结果:
- 13 个测试全部通过。
覆盖内容:
- 治理模型注册与外键。
- 治理 API 路由与审计事件。
- formal-launch 导入和发布批次生成。
- 权限元数据暴露。
## 当前状态
`GOV-API-001 ~ GOV-API-006` 已完成,backlog 已更新为 `COMPLETED`。
尚未进入:
- `GOV-API-007` 模板、规则、知识库的发布/回滚接口。
- `5C` 前端复核总控中心、复核工作台、资产详情页、上线批准页。
- `5D` 门禁切换为数据库读模式。
## 下一步建议
最自然的下一步是进入 `5C / GOV-WEB-001 ~ GOV-WEB-004`,先把复核总控中心、复核人工作台、资产详情页和上线批准页做起来,让刚完成的治理接口真正可用。