Просмотр исходного кода

feat: add governance operational metrics

马小龙 3 недель назад
Родитель
Сommit
f074c69ebe
28 измененных файлов с 2588 добавлено и 3 удалено
  1. 58 0
      app/api/data_development/routes.py
  2. 5 0
      app/core/data_research/errors.py
  3. 333 0
      app/core/data_research/governance_metric_repository.py
  4. 159 0
      app/core/data_research/governance_metrics.py
  5. 2 0
      app/core/system/permissions.py
  6. 58 0
      deployment/app/api/data_development/routes.py
  7. 5 0
      deployment/app/core/data_research/errors.py
  8. 333 0
      deployment/app/core/data_research/governance_metric_repository.py
  9. 159 0
      deployment/app/core/data_research/governance_metrics.py
  10. 2 0
      deployment/app/core/system/permissions.py
  11. 1 0
      docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md
  12. 2 2
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  13. 28 0
      docs/architecture/DATA_MODEL.md
  14. 41 1
      docs/architecture/OPENAPI.yaml
  15. 117 0
      docs/superpowers/plans/2026-07-30-wp11-governance-operational-metrics.md
  16. 8 0
      frontend/src/api/dataDevelopment.js
  17. 12 0
      frontend/src/router/routes.js
  18. 218 0
      frontend/src/views/dataGovernance/development/governanceMetrics.vue
  19. 102 0
      frontend/src/views/dataGovernance/development/governanceMetricsModel.js
  20. 1 0
      frontend/src/views/dataGovernance/development/index.vue
  21. 76 0
      frontend/tests/governance-metrics-model.test.mjs
  22. 12 0
      scripts/generate_openapi.py
  23. 164 0
      tests/data_research/test_governance_metrics.py
  24. 173 0
      tests/data_research/test_governance_metrics_api.py
  25. 63 0
      tests/data_research/test_governance_metrics_frontend_contract.py
  26. 424 0
      tests/integration/test_governance_metrics_postgres.py
  27. 24 0
      tests/test_architecture_artifacts.py
  28. 8 0
      tests/test_permission_matrix.py

+ 58 - 0
app/api/data_development/routes.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import io
 import logging
+import uuid
 
 from flask import current_app, g, jsonify, request, send_file
 
@@ -426,6 +427,37 @@ def _identity():
     return getattr(g, "current_user", {}) or {}
 
 
+def get_governance_metrics_service():
+    from app.core.data_research.governance_metric_repository import (
+        SqlAlchemyGovernanceMetricRepository,
+    )
+    from app.core.data_research.governance_metrics import (
+        GovernanceMetricsService,
+    )
+
+    return GovernanceMetricsService(
+        SqlAlchemyGovernanceMetricRepository(db.session)
+    )
+
+
+def _governance_metric_access():
+    from app.core.data_research.governance_metrics import (
+        GovernanceMetricAccess,
+    )
+    from app.core.knowledge.access import build_access_context
+
+    context = build_access_context(
+        db.session,
+        identity=_identity(),
+        requested_business_domains=None,
+        correlation_id=str(uuid.uuid4()),
+    )
+    return GovernanceMetricAccess(
+        global_access=context.global_access,
+        business_domain_uids=tuple(context.business_domain_uids),
+    )
+
+
 def _record(record):
     return {
         "uid": str(record.uid),
@@ -2146,6 +2178,32 @@ def get_device_root_cause():
         return _error(error)
 
 
+@bp.route("/governance-metrics/summary", methods=["GET"])
+def get_governance_metric_summary():
+    try:
+        result = get_governance_metrics_service().summary(
+            _governance_metric_access()
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/governance-metrics/details", methods=["GET"])
+def get_governance_metric_details():
+    try:
+        result = get_governance_metrics_service().details(
+            _governance_metric_access(),
+            metric=request.args.get("metric"),
+            state=request.args.get("state"),
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

+ 5 - 0
app/core/data_research/errors.py

@@ -153,3 +153,8 @@ class DeviceObservabilityNotFound(DataResearchError):
 class DeviceObservabilityConflict(DataResearchError):
     code = "DEVICE_OBSERVABILITY_CONFLICT"
     http_status = 409
+
+
+class GovernanceMetricInvalid(DataResearchError):
+    code = "GOVERNANCE_METRIC_INVALID"
+    http_status = 400

+ 333 - 0
app/core/data_research/governance_metric_repository.py

@@ -0,0 +1,333 @@
+"""Authorization-aware PostgreSQL queries for governance operational metrics."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import text
+
+_SCOPE_CTES = """
+WITH authorized_sources AS (
+    SELECT s.uid
+    FROM public.ingestion_sources s
+    WHERE s.status = 'active'
+      AND (
+        :global_access
+        OR EXISTS (
+            SELECT 1
+            FROM jsonb_array_elements_text(
+                COALESCE(
+                    s.permission_scope -> 'business_domains',
+                    '[]'::jsonb
+                )
+            ) AS domain(uid)
+            WHERE domain.uid = ANY(
+                CAST(:business_domain_uids AS text[])
+            )
+        )
+      )
+),
+visible_assets AS (
+    SELECT a.*
+    FROM public.device_assets a
+    WHERE a.status = 'active'
+      AND (
+        :global_access
+        OR EXISTS (
+            SELECT 1
+            FROM public.device_asset_source_mappings mapping
+            JOIN authorized_sources source
+              ON source.uid = mapping.source_uid
+            WHERE mapping.asset_uid = a.uid
+        )
+      )
+),
+visible_mappings AS (
+    SELECT mapping.*
+    FROM public.device_asset_source_mappings mapping
+    JOIN authorized_sources source ON source.uid = mapping.source_uid
+    JOIN visible_assets asset ON asset.uid = mapping.asset_uid
+),
+active_merges AS (
+    SELECT merge.*
+    FROM public.device_entity_merge_events merge
+    JOIN visible_assets canonical
+      ON canonical.uid = merge.canonical_asset_uid
+    JOIN visible_assets member
+      ON member.uid = merge.member_asset_uid
+    LEFT JOIN public.device_entity_merge_rollbacks rollback
+      ON rollback.merge_uid = merge.uid
+    WHERE rollback.uid IS NULL
+),
+mapped_assets AS (
+    SELECT canonical_asset_uid AS asset_uid, uid AS merge_uid
+    FROM active_merges
+    UNION ALL
+    SELECT member_asset_uid AS asset_uid, uid AS merge_uid
+    FROM active_merges
+),
+visible_issues AS (
+    SELECT issue.*
+    FROM public.device_quality_issues issue
+    JOIN visible_assets asset ON asset.uid = issue.asset_uid
+    WHERE
+      :global_access
+      OR CASE
+        WHEN issue.source_uid IS NOT NULL THEN EXISTS (
+            SELECT 1
+            FROM authorized_sources source
+            WHERE source.uid = issue.source_uid
+        )
+        WHEN issue.source_mapping_uid IS NOT NULL THEN EXISTS (
+            SELECT 1
+            FROM visible_mappings mapping
+            WHERE mapping.uid = issue.source_mapping_uid
+        )
+        ELSE TRUE
+      END
+)
+"""
+
+_ASSET_DETAIL_SELECT = """
+SELECT
+    asset.uid::text AS asset_uid,
+    asset.name AS asset_name,
+    asset.asset_type,
+    asset.location,
+    asset.organization,
+    asset.responsible_person,
+    COALESCE(
+        (
+            SELECT array_agg(DISTINCT mapping.source_code ORDER BY mapping.source_code)
+            FROM visible_mappings mapping
+            WHERE mapping.asset_uid = asset.uid
+        ),
+        ARRAY[]::varchar[]
+    ) AS source_codes,
+    ARRAY_REMOVE(
+        ARRAY[
+            CASE WHEN NULLIF(BTRIM(asset.name), '') IS NULL
+                THEN 'name' END,
+            CASE WHEN NULLIF(BTRIM(asset.location), '') IS NULL
+                THEN 'location' END,
+            CASE WHEN NULLIF(BTRIM(asset.organization), '') IS NULL
+                THEN 'organization' END,
+            CASE WHEN NULLIF(BTRIM(asset.responsible_person), '') IS NULL
+                THEN 'responsible_person' END,
+            CASE WHEN NOT EXISTS (
+                SELECT 1
+                FROM visible_mappings mapping
+                WHERE mapping.asset_uid = asset.uid
+            ) THEN 'source_mapping' END
+        ],
+        NULL
+    ) AS missing_fields,
+    EXISTS (
+        SELECT 1 FROM mapped_assets mapped
+        WHERE mapped.asset_uid = asset.uid
+    ) AS mapped,
+    COALESCE(
+        (
+            SELECT array_agg(DISTINCT mapped.merge_uid::text ORDER BY mapped.merge_uid::text)
+            FROM mapped_assets mapped
+            WHERE mapped.asset_uid = asset.uid
+        ),
+        ARRAY[]::text[]
+    ) AS merge_uids
+FROM visible_assets asset
+"""
+
+_ISSUE_DETAIL_SELECT = """
+SELECT
+    issue.uid::text AS issue_uid,
+    issue.issue_code,
+    issue.asset_uid::text AS asset_uid,
+    asset.name AS asset_name,
+    issue.rule_code,
+    issue.field_name,
+    issue.priority,
+    issue.status,
+    issue.occurrence_number,
+    issue.assignee_uid::text AS assignee_uid,
+    issue.due_at,
+    issue.closed_at,
+    (
+        issue.status <> 'closed'
+        AND issue.due_at IS NOT NULL
+        AND issue.due_at < CURRENT_TIMESTAMP
+    ) AS overdue
+FROM visible_issues issue
+JOIN visible_assets asset ON asset.uid = issue.asset_uid
+"""
+
+_ASSET_STATE_PREDICATES = {
+    ("asset_completeness", "complete"): (
+        "NULLIF(BTRIM(asset.name), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.location), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.organization), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL "
+        "AND EXISTS (SELECT 1 FROM visible_mappings mapping "
+        "WHERE mapping.asset_uid = asset.uid)"
+    ),
+    ("asset_completeness", "incomplete"): (
+        "NOT (NULLIF(BTRIM(asset.name), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.location), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.organization), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL "
+        "AND EXISTS (SELECT 1 FROM visible_mappings mapping "
+        "WHERE mapping.asset_uid = asset.uid))"
+    ),
+    ("responsibility_coverage", "covered"): (
+        "NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL"
+    ),
+    ("responsibility_coverage", "uncovered"): (
+        "NULLIF(BTRIM(asset.responsible_person), '') IS NULL"
+    ),
+    ("entity_mapping", "mapped"): (
+        "EXISTS (SELECT 1 FROM mapped_assets mapped "
+        "WHERE mapped.asset_uid = asset.uid)"
+    ),
+    ("entity_mapping", "unmapped"): (
+        "NOT EXISTS (SELECT 1 FROM mapped_assets mapped "
+        "WHERE mapped.asset_uid = asset.uid)"
+    ),
+}
+
+_ISSUE_STATE_PREDICATES = {
+    ("issue_closure", "closed"): "issue.status = 'closed'",
+    ("issue_closure", "open"): "issue.status <> 'closed'",
+    ("issue_recurrence", "recurrent"): "issue.occurrence_number > 1",
+    ("issue_recurrence", "first_occurrence"): "issue.occurrence_number = 1",
+}
+
+
+class SqlAlchemyGovernanceMetricRepository:
+    """Calculate and trace metrics without returning unauthorized raw records."""
+
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _params(access, **extra):
+        return {
+            "global_access": bool(access.global_access),
+            "business_domain_uids": list(access.business_domain_uids),
+            **extra,
+        }
+
+    def summary_counts(self, access):
+        row = (
+            self.session.execute(
+                text(
+                    _SCOPE_CTES
+                    + """
+                    SELECT
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_assets asset
+                            WHERE NULLIF(BTRIM(asset.name), '') IS NOT NULL
+                              AND NULLIF(BTRIM(asset.location), '') IS NOT NULL
+                              AND NULLIF(BTRIM(asset.organization), '') IS NOT NULL
+                              AND NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL
+                              AND EXISTS (
+                                  SELECT 1
+                                  FROM visible_mappings mapping
+                                  WHERE mapping.asset_uid = asset.uid
+                              )
+                        ) AS complete_assets,
+                        (SELECT COUNT(*) FROM visible_assets) AS total_assets,
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_assets asset
+                            WHERE NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL
+                        ) AS responsible_assets,
+                        (
+                            SELECT COUNT(DISTINCT asset_uid)
+                            FROM mapped_assets
+                        ) AS mapped_assets,
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_issues
+                            WHERE status = 'closed'
+                        ) AS closed_issues,
+                        (SELECT COUNT(*) FROM visible_issues) AS total_issues,
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_issues
+                            WHERE occurrence_number > 1
+                        ) AS recurrent_issues
+                    """
+                ),
+                self._params(access),
+            )
+            .mappings()
+            .one()
+        )
+        total_assets = int(row["total_assets"])
+        total_issues = int(row["total_issues"])
+        return {
+            "asset_completeness": (
+                int(row["complete_assets"]),
+                total_assets,
+            ),
+            "responsibility_coverage": (
+                int(row["responsible_assets"]),
+                total_assets,
+            ),
+            "entity_mapping": (int(row["mapped_assets"]), total_assets),
+            "issue_closure": (int(row["closed_issues"]), total_issues),
+            "issue_recurrence": (
+                int(row["recurrent_issues"]),
+                total_issues,
+            ),
+        }
+
+    @staticmethod
+    def _safe_row(row):
+        result = dict(row)
+        for field in ("due_at", "closed_at"):
+            value = result.get(field)
+            if isinstance(value, datetime):
+                result[field] = value.isoformat()
+        for field in ("source_codes", "missing_fields", "merge_uids"):
+            if field in result:
+                result[field] = list(result[field] or [])
+        return result
+
+    def list_details(self, access, *, metric, state, page, page_size):
+        predicate = _ASSET_STATE_PREDICATES.get((metric, state))
+        select_clause = _ASSET_DETAIL_SELECT
+        order_clause = "ORDER BY asset.name, asset.uid"
+        if predicate is None:
+            predicate = _ISSUE_STATE_PREDICATES[(metric, state)]
+            select_clause = _ISSUE_DETAIL_SELECT
+            order_clause = "ORDER BY issue.created_at DESC, issue.uid DESC"
+
+        filtered_query = (
+            _SCOPE_CTES
+            + ", filtered_details AS ("
+            + select_clause
+            + f" WHERE {predicate}) "
+        )
+        total = int(
+            self.session.execute(
+                text(filtered_query + "SELECT COUNT(*) FROM filtered_details"),
+                self._params(access),
+            ).scalar()
+            or 0
+        )
+        offset = (page - 1) * page_size
+        rows = (
+            self.session.execute(
+                text(
+                    _SCOPE_CTES
+                    + select_clause
+                    + f" WHERE {predicate} {order_clause} "
+                    + "LIMIT :limit OFFSET :offset"
+                ),
+                self._params(access, limit=page_size, offset=offset),
+            )
+            .mappings()
+            .all()
+        )
+        return [self._safe_row(row) for row in rows], total

+ 159 - 0
app/core/data_research/governance_metrics.py

@@ -0,0 +1,159 @@
+"""Read-only governance operational metric contracts and orchestration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from app.core.data_research.errors import GovernanceMetricInvalid
+
+MAX_PAGE_SIZE = 100
+
+METRIC_DEFINITIONS = (
+    {
+        "code": "asset_completeness",
+        "name": "台账完整率",
+        "definition": (
+            "名称、位置、组织、责任人及授权有效来源映射均完整的在用设备"
+            "占可见在用设备的比例"
+        ),
+        "states": ("complete", "incomplete"),
+    },
+    {
+        "code": "responsibility_coverage",
+        "name": "责任覆盖率",
+        "definition": "已明确责任人的在用设备占可见在用设备的比例",
+        "states": ("covered", "uncovered"),
+    },
+    {
+        "code": "entity_mapping",
+        "name": "实体映射率",
+        "definition": (
+            "参与有效且未回滚实体合并的在用设备占可见在用设备的比例"
+        ),
+        "states": ("mapped", "unmapped"),
+    },
+    {
+        "code": "issue_closure",
+        "name": "问题闭环率",
+        "definition": "已关闭质量问题占可见质量问题总数的比例",
+        "states": ("closed", "open"),
+    },
+    {
+        "code": "issue_recurrence",
+        "name": "问题复发率",
+        "definition": "发生次数大于一次的质量问题占可见质量问题总数的比例",
+        "states": ("recurrent", "first_occurrence"),
+    },
+)
+
+METRICS_BY_CODE = {item["code"]: item for item in METRIC_DEFINITIONS}
+
+
+@dataclass(frozen=True)
+class GovernanceMetricAccess:
+    """Caller-visible business-domain boundary for aggregate and detail queries."""
+
+    global_access: bool
+    business_domain_uids: tuple[str, ...] = ()
+
+    def __post_init__(self):
+        normalized = tuple(
+            sorted(
+                {
+                    str(value).strip()
+                    for value in self.business_domain_uids
+                    if str(value).strip()
+                }
+            )
+        )
+        object.__setattr__(self, "business_domain_uids", normalized)
+
+
+def _positive_integer(value: Any, field: str, *, maximum: int | None = None) -> int:
+    try:
+        parsed = int(value)
+    except (TypeError, ValueError) as error:
+        raise GovernanceMetricInvalid(f"{field} must be a positive integer") from error
+    if parsed < 1 or (maximum is not None and parsed > maximum):
+        suffix = f" between 1 and {maximum}" if maximum is not None else ""
+        raise GovernanceMetricInvalid(f"{field} must be a positive integer{suffix}")
+    return parsed
+
+
+class GovernanceMetricsService:
+    """Expose fixed metric definitions over an authorization-aware repository."""
+
+    def __init__(self, repository):
+        self.repository = repository
+
+    @staticmethod
+    def _scope(access: GovernanceMetricAccess) -> dict[str, Any]:
+        return {
+            "global_access": access.global_access,
+            "business_domain_uids": list(access.business_domain_uids),
+        }
+
+    def summary(self, access: GovernanceMetricAccess) -> dict[str, Any]:
+        counts = self.repository.summary_counts(access)
+        metrics = []
+        for definition in METRIC_DEFINITIONS:
+            numerator, denominator = counts[definition["code"]]
+            metrics.append(
+                {
+                    "code": definition["code"],
+                    "name": definition["name"],
+                    "definition": definition["definition"],
+                    "numerator": int(numerator),
+                    "denominator": int(denominator),
+                    "rate": (
+                        round(int(numerator) / int(denominator), 6)
+                        if denominator
+                        else None
+                    ),
+                    "status": "available" if denominator else "no_data",
+                    "states": list(definition["states"]),
+                }
+            )
+        return {"scope": self._scope(access), "metrics": metrics}
+
+    def details(
+        self,
+        access: GovernanceMetricAccess,
+        *,
+        metric: str,
+        state: str,
+        page: Any = 1,
+        page_size: Any = 20,
+    ) -> dict[str, Any]:
+        definition = METRICS_BY_CODE.get(str(metric or "").strip())
+        if definition is None:
+            raise GovernanceMetricInvalid("unsupported metric")
+        normalized_state = str(state or "").strip()
+        if normalized_state not in definition["states"]:
+            raise GovernanceMetricInvalid("unsupported state for metric")
+        normalized_page = _positive_integer(page, "page")
+        normalized_page_size = _positive_integer(
+            page_size,
+            "page_size",
+            maximum=MAX_PAGE_SIZE,
+        )
+        records, total = self.repository.list_details(
+            access,
+            metric=definition["code"],
+            state=normalized_state,
+            page=normalized_page,
+            page_size=normalized_page_size,
+        )
+        return {
+            "metric": {
+                "code": definition["code"],
+                "name": definition["name"],
+                "definition": definition["definition"],
+            },
+            "state": normalized_state,
+            "records": list(records),
+            "page": normalized_page,
+            "page_size": normalized_page_size,
+            "total": int(total),
+        }

+ 2 - 0
app/core/system/permissions.py

@@ -170,6 +170,8 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         return (KNOWLEDGE_MANAGE,)
     if path.startswith("/api/system/users"):
         return (MANAGE_USERS,)
+    if path.startswith("/api/development/v1/governance-metrics"):
+        return (READ_GOVERNANCE,)
     if path.startswith("/api/development/v1/device-assets"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 58 - 0
deployment/app/api/data_development/routes.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import io
 import logging
+import uuid
 
 from flask import current_app, g, jsonify, request, send_file
 
@@ -426,6 +427,37 @@ def _identity():
     return getattr(g, "current_user", {}) or {}
 
 
+def get_governance_metrics_service():
+    from app.core.data_research.governance_metric_repository import (
+        SqlAlchemyGovernanceMetricRepository,
+    )
+    from app.core.data_research.governance_metrics import (
+        GovernanceMetricsService,
+    )
+
+    return GovernanceMetricsService(
+        SqlAlchemyGovernanceMetricRepository(db.session)
+    )
+
+
+def _governance_metric_access():
+    from app.core.data_research.governance_metrics import (
+        GovernanceMetricAccess,
+    )
+    from app.core.knowledge.access import build_access_context
+
+    context = build_access_context(
+        db.session,
+        identity=_identity(),
+        requested_business_domains=None,
+        correlation_id=str(uuid.uuid4()),
+    )
+    return GovernanceMetricAccess(
+        global_access=context.global_access,
+        business_domain_uids=tuple(context.business_domain_uids),
+    )
+
+
 def _record(record):
     return {
         "uid": str(record.uid),
@@ -2146,6 +2178,32 @@ def get_device_root_cause():
         return _error(error)
 
 
+@bp.route("/governance-metrics/summary", methods=["GET"])
+def get_governance_metric_summary():
+    try:
+        result = get_governance_metrics_service().summary(
+            _governance_metric_access()
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/governance-metrics/details", methods=["GET"])
+def get_governance_metric_details():
+    try:
+        result = get_governance_metrics_service().details(
+            _governance_metric_access(),
+            metric=request.args.get("metric"),
+            state=request.args.get("state"),
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

+ 5 - 0
deployment/app/core/data_research/errors.py

@@ -153,3 +153,8 @@ class DeviceObservabilityNotFound(DataResearchError):
 class DeviceObservabilityConflict(DataResearchError):
     code = "DEVICE_OBSERVABILITY_CONFLICT"
     http_status = 409
+
+
+class GovernanceMetricInvalid(DataResearchError):
+    code = "GOVERNANCE_METRIC_INVALID"
+    http_status = 400

+ 333 - 0
deployment/app/core/data_research/governance_metric_repository.py

@@ -0,0 +1,333 @@
+"""Authorization-aware PostgreSQL queries for governance operational metrics."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import text
+
+_SCOPE_CTES = """
+WITH authorized_sources AS (
+    SELECT s.uid
+    FROM public.ingestion_sources s
+    WHERE s.status = 'active'
+      AND (
+        :global_access
+        OR EXISTS (
+            SELECT 1
+            FROM jsonb_array_elements_text(
+                COALESCE(
+                    s.permission_scope -> 'business_domains',
+                    '[]'::jsonb
+                )
+            ) AS domain(uid)
+            WHERE domain.uid = ANY(
+                CAST(:business_domain_uids AS text[])
+            )
+        )
+      )
+),
+visible_assets AS (
+    SELECT a.*
+    FROM public.device_assets a
+    WHERE a.status = 'active'
+      AND (
+        :global_access
+        OR EXISTS (
+            SELECT 1
+            FROM public.device_asset_source_mappings mapping
+            JOIN authorized_sources source
+              ON source.uid = mapping.source_uid
+            WHERE mapping.asset_uid = a.uid
+        )
+      )
+),
+visible_mappings AS (
+    SELECT mapping.*
+    FROM public.device_asset_source_mappings mapping
+    JOIN authorized_sources source ON source.uid = mapping.source_uid
+    JOIN visible_assets asset ON asset.uid = mapping.asset_uid
+),
+active_merges AS (
+    SELECT merge.*
+    FROM public.device_entity_merge_events merge
+    JOIN visible_assets canonical
+      ON canonical.uid = merge.canonical_asset_uid
+    JOIN visible_assets member
+      ON member.uid = merge.member_asset_uid
+    LEFT JOIN public.device_entity_merge_rollbacks rollback
+      ON rollback.merge_uid = merge.uid
+    WHERE rollback.uid IS NULL
+),
+mapped_assets AS (
+    SELECT canonical_asset_uid AS asset_uid, uid AS merge_uid
+    FROM active_merges
+    UNION ALL
+    SELECT member_asset_uid AS asset_uid, uid AS merge_uid
+    FROM active_merges
+),
+visible_issues AS (
+    SELECT issue.*
+    FROM public.device_quality_issues issue
+    JOIN visible_assets asset ON asset.uid = issue.asset_uid
+    WHERE
+      :global_access
+      OR CASE
+        WHEN issue.source_uid IS NOT NULL THEN EXISTS (
+            SELECT 1
+            FROM authorized_sources source
+            WHERE source.uid = issue.source_uid
+        )
+        WHEN issue.source_mapping_uid IS NOT NULL THEN EXISTS (
+            SELECT 1
+            FROM visible_mappings mapping
+            WHERE mapping.uid = issue.source_mapping_uid
+        )
+        ELSE TRUE
+      END
+)
+"""
+
+_ASSET_DETAIL_SELECT = """
+SELECT
+    asset.uid::text AS asset_uid,
+    asset.name AS asset_name,
+    asset.asset_type,
+    asset.location,
+    asset.organization,
+    asset.responsible_person,
+    COALESCE(
+        (
+            SELECT array_agg(DISTINCT mapping.source_code ORDER BY mapping.source_code)
+            FROM visible_mappings mapping
+            WHERE mapping.asset_uid = asset.uid
+        ),
+        ARRAY[]::varchar[]
+    ) AS source_codes,
+    ARRAY_REMOVE(
+        ARRAY[
+            CASE WHEN NULLIF(BTRIM(asset.name), '') IS NULL
+                THEN 'name' END,
+            CASE WHEN NULLIF(BTRIM(asset.location), '') IS NULL
+                THEN 'location' END,
+            CASE WHEN NULLIF(BTRIM(asset.organization), '') IS NULL
+                THEN 'organization' END,
+            CASE WHEN NULLIF(BTRIM(asset.responsible_person), '') IS NULL
+                THEN 'responsible_person' END,
+            CASE WHEN NOT EXISTS (
+                SELECT 1
+                FROM visible_mappings mapping
+                WHERE mapping.asset_uid = asset.uid
+            ) THEN 'source_mapping' END
+        ],
+        NULL
+    ) AS missing_fields,
+    EXISTS (
+        SELECT 1 FROM mapped_assets mapped
+        WHERE mapped.asset_uid = asset.uid
+    ) AS mapped,
+    COALESCE(
+        (
+            SELECT array_agg(DISTINCT mapped.merge_uid::text ORDER BY mapped.merge_uid::text)
+            FROM mapped_assets mapped
+            WHERE mapped.asset_uid = asset.uid
+        ),
+        ARRAY[]::text[]
+    ) AS merge_uids
+FROM visible_assets asset
+"""
+
+_ISSUE_DETAIL_SELECT = """
+SELECT
+    issue.uid::text AS issue_uid,
+    issue.issue_code,
+    issue.asset_uid::text AS asset_uid,
+    asset.name AS asset_name,
+    issue.rule_code,
+    issue.field_name,
+    issue.priority,
+    issue.status,
+    issue.occurrence_number,
+    issue.assignee_uid::text AS assignee_uid,
+    issue.due_at,
+    issue.closed_at,
+    (
+        issue.status <> 'closed'
+        AND issue.due_at IS NOT NULL
+        AND issue.due_at < CURRENT_TIMESTAMP
+    ) AS overdue
+FROM visible_issues issue
+JOIN visible_assets asset ON asset.uid = issue.asset_uid
+"""
+
+_ASSET_STATE_PREDICATES = {
+    ("asset_completeness", "complete"): (
+        "NULLIF(BTRIM(asset.name), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.location), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.organization), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL "
+        "AND EXISTS (SELECT 1 FROM visible_mappings mapping "
+        "WHERE mapping.asset_uid = asset.uid)"
+    ),
+    ("asset_completeness", "incomplete"): (
+        "NOT (NULLIF(BTRIM(asset.name), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.location), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.organization), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL "
+        "AND EXISTS (SELECT 1 FROM visible_mappings mapping "
+        "WHERE mapping.asset_uid = asset.uid))"
+    ),
+    ("responsibility_coverage", "covered"): (
+        "NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL"
+    ),
+    ("responsibility_coverage", "uncovered"): (
+        "NULLIF(BTRIM(asset.responsible_person), '') IS NULL"
+    ),
+    ("entity_mapping", "mapped"): (
+        "EXISTS (SELECT 1 FROM mapped_assets mapped "
+        "WHERE mapped.asset_uid = asset.uid)"
+    ),
+    ("entity_mapping", "unmapped"): (
+        "NOT EXISTS (SELECT 1 FROM mapped_assets mapped "
+        "WHERE mapped.asset_uid = asset.uid)"
+    ),
+}
+
+_ISSUE_STATE_PREDICATES = {
+    ("issue_closure", "closed"): "issue.status = 'closed'",
+    ("issue_closure", "open"): "issue.status <> 'closed'",
+    ("issue_recurrence", "recurrent"): "issue.occurrence_number > 1",
+    ("issue_recurrence", "first_occurrence"): "issue.occurrence_number = 1",
+}
+
+
+class SqlAlchemyGovernanceMetricRepository:
+    """Calculate and trace metrics without returning unauthorized raw records."""
+
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _params(access, **extra):
+        return {
+            "global_access": bool(access.global_access),
+            "business_domain_uids": list(access.business_domain_uids),
+            **extra,
+        }
+
+    def summary_counts(self, access):
+        row = (
+            self.session.execute(
+                text(
+                    _SCOPE_CTES
+                    + """
+                    SELECT
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_assets asset
+                            WHERE NULLIF(BTRIM(asset.name), '') IS NOT NULL
+                              AND NULLIF(BTRIM(asset.location), '') IS NOT NULL
+                              AND NULLIF(BTRIM(asset.organization), '') IS NOT NULL
+                              AND NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL
+                              AND EXISTS (
+                                  SELECT 1
+                                  FROM visible_mappings mapping
+                                  WHERE mapping.asset_uid = asset.uid
+                              )
+                        ) AS complete_assets,
+                        (SELECT COUNT(*) FROM visible_assets) AS total_assets,
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_assets asset
+                            WHERE NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL
+                        ) AS responsible_assets,
+                        (
+                            SELECT COUNT(DISTINCT asset_uid)
+                            FROM mapped_assets
+                        ) AS mapped_assets,
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_issues
+                            WHERE status = 'closed'
+                        ) AS closed_issues,
+                        (SELECT COUNT(*) FROM visible_issues) AS total_issues,
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_issues
+                            WHERE occurrence_number > 1
+                        ) AS recurrent_issues
+                    """
+                ),
+                self._params(access),
+            )
+            .mappings()
+            .one()
+        )
+        total_assets = int(row["total_assets"])
+        total_issues = int(row["total_issues"])
+        return {
+            "asset_completeness": (
+                int(row["complete_assets"]),
+                total_assets,
+            ),
+            "responsibility_coverage": (
+                int(row["responsible_assets"]),
+                total_assets,
+            ),
+            "entity_mapping": (int(row["mapped_assets"]), total_assets),
+            "issue_closure": (int(row["closed_issues"]), total_issues),
+            "issue_recurrence": (
+                int(row["recurrent_issues"]),
+                total_issues,
+            ),
+        }
+
+    @staticmethod
+    def _safe_row(row):
+        result = dict(row)
+        for field in ("due_at", "closed_at"):
+            value = result.get(field)
+            if isinstance(value, datetime):
+                result[field] = value.isoformat()
+        for field in ("source_codes", "missing_fields", "merge_uids"):
+            if field in result:
+                result[field] = list(result[field] or [])
+        return result
+
+    def list_details(self, access, *, metric, state, page, page_size):
+        predicate = _ASSET_STATE_PREDICATES.get((metric, state))
+        select_clause = _ASSET_DETAIL_SELECT
+        order_clause = "ORDER BY asset.name, asset.uid"
+        if predicate is None:
+            predicate = _ISSUE_STATE_PREDICATES[(metric, state)]
+            select_clause = _ISSUE_DETAIL_SELECT
+            order_clause = "ORDER BY issue.created_at DESC, issue.uid DESC"
+
+        filtered_query = (
+            _SCOPE_CTES
+            + ", filtered_details AS ("
+            + select_clause
+            + f" WHERE {predicate}) "
+        )
+        total = int(
+            self.session.execute(
+                text(filtered_query + "SELECT COUNT(*) FROM filtered_details"),
+                self._params(access),
+            ).scalar()
+            or 0
+        )
+        offset = (page - 1) * page_size
+        rows = (
+            self.session.execute(
+                text(
+                    _SCOPE_CTES
+                    + select_clause
+                    + f" WHERE {predicate} {order_clause} "
+                    + "LIMIT :limit OFFSET :offset"
+                ),
+                self._params(access, limit=page_size, offset=offset),
+            )
+            .mappings()
+            .all()
+        )
+        return [self._safe_row(row) for row in rows], total

+ 159 - 0
deployment/app/core/data_research/governance_metrics.py

@@ -0,0 +1,159 @@
+"""Read-only governance operational metric contracts and orchestration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from app.core.data_research.errors import GovernanceMetricInvalid
+
+MAX_PAGE_SIZE = 100
+
+METRIC_DEFINITIONS = (
+    {
+        "code": "asset_completeness",
+        "name": "台账完整率",
+        "definition": (
+            "名称、位置、组织、责任人及授权有效来源映射均完整的在用设备"
+            "占可见在用设备的比例"
+        ),
+        "states": ("complete", "incomplete"),
+    },
+    {
+        "code": "responsibility_coverage",
+        "name": "责任覆盖率",
+        "definition": "已明确责任人的在用设备占可见在用设备的比例",
+        "states": ("covered", "uncovered"),
+    },
+    {
+        "code": "entity_mapping",
+        "name": "实体映射率",
+        "definition": (
+            "参与有效且未回滚实体合并的在用设备占可见在用设备的比例"
+        ),
+        "states": ("mapped", "unmapped"),
+    },
+    {
+        "code": "issue_closure",
+        "name": "问题闭环率",
+        "definition": "已关闭质量问题占可见质量问题总数的比例",
+        "states": ("closed", "open"),
+    },
+    {
+        "code": "issue_recurrence",
+        "name": "问题复发率",
+        "definition": "发生次数大于一次的质量问题占可见质量问题总数的比例",
+        "states": ("recurrent", "first_occurrence"),
+    },
+)
+
+METRICS_BY_CODE = {item["code"]: item for item in METRIC_DEFINITIONS}
+
+
+@dataclass(frozen=True)
+class GovernanceMetricAccess:
+    """Caller-visible business-domain boundary for aggregate and detail queries."""
+
+    global_access: bool
+    business_domain_uids: tuple[str, ...] = ()
+
+    def __post_init__(self):
+        normalized = tuple(
+            sorted(
+                {
+                    str(value).strip()
+                    for value in self.business_domain_uids
+                    if str(value).strip()
+                }
+            )
+        )
+        object.__setattr__(self, "business_domain_uids", normalized)
+
+
+def _positive_integer(value: Any, field: str, *, maximum: int | None = None) -> int:
+    try:
+        parsed = int(value)
+    except (TypeError, ValueError) as error:
+        raise GovernanceMetricInvalid(f"{field} must be a positive integer") from error
+    if parsed < 1 or (maximum is not None and parsed > maximum):
+        suffix = f" between 1 and {maximum}" if maximum is not None else ""
+        raise GovernanceMetricInvalid(f"{field} must be a positive integer{suffix}")
+    return parsed
+
+
+class GovernanceMetricsService:
+    """Expose fixed metric definitions over an authorization-aware repository."""
+
+    def __init__(self, repository):
+        self.repository = repository
+
+    @staticmethod
+    def _scope(access: GovernanceMetricAccess) -> dict[str, Any]:
+        return {
+            "global_access": access.global_access,
+            "business_domain_uids": list(access.business_domain_uids),
+        }
+
+    def summary(self, access: GovernanceMetricAccess) -> dict[str, Any]:
+        counts = self.repository.summary_counts(access)
+        metrics = []
+        for definition in METRIC_DEFINITIONS:
+            numerator, denominator = counts[definition["code"]]
+            metrics.append(
+                {
+                    "code": definition["code"],
+                    "name": definition["name"],
+                    "definition": definition["definition"],
+                    "numerator": int(numerator),
+                    "denominator": int(denominator),
+                    "rate": (
+                        round(int(numerator) / int(denominator), 6)
+                        if denominator
+                        else None
+                    ),
+                    "status": "available" if denominator else "no_data",
+                    "states": list(definition["states"]),
+                }
+            )
+        return {"scope": self._scope(access), "metrics": metrics}
+
+    def details(
+        self,
+        access: GovernanceMetricAccess,
+        *,
+        metric: str,
+        state: str,
+        page: Any = 1,
+        page_size: Any = 20,
+    ) -> dict[str, Any]:
+        definition = METRICS_BY_CODE.get(str(metric or "").strip())
+        if definition is None:
+            raise GovernanceMetricInvalid("unsupported metric")
+        normalized_state = str(state or "").strip()
+        if normalized_state not in definition["states"]:
+            raise GovernanceMetricInvalid("unsupported state for metric")
+        normalized_page = _positive_integer(page, "page")
+        normalized_page_size = _positive_integer(
+            page_size,
+            "page_size",
+            maximum=MAX_PAGE_SIZE,
+        )
+        records, total = self.repository.list_details(
+            access,
+            metric=definition["code"],
+            state=normalized_state,
+            page=normalized_page,
+            page_size=normalized_page_size,
+        )
+        return {
+            "metric": {
+                "code": definition["code"],
+                "name": definition["name"],
+                "definition": definition["definition"],
+            },
+            "state": normalized_state,
+            "records": list(records),
+            "page": normalized_page,
+            "page_size": normalized_page_size,
+            "total": int(total),
+        }

+ 2 - 0
deployment/app/core/system/permissions.py

@@ -170,6 +170,8 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         return (KNOWLEDGE_MANAGE,)
     if path.startswith("/api/system/users"):
         return (MANAGE_USERS,)
+    if path.startswith("/api/development/v1/governance-metrics"):
+        return (READ_GOVERNANCE,)
     if path.startswith("/api/development/v1/device-assets"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 1 - 0
docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md

@@ -163,6 +163,7 @@ P2 不阻塞第一阶段验收。没有完成的 P2 功能必须保留接口和
 | WP-08 | 工程完成,待企业整改流程验收 | WP-07 违规证据快照转问题;同类未关闭问题幂等去重;有效用户分派和期限;整改提交、独立责任人复核、关闭、退回与重开;乐观锁和追加式处理时间线;逾期、发生次数、复发问题组及复发率;编辑者与复核者权限分离;OpenAPI 191 项;真实 PostgreSQL 定向验证 | 需要企业提供实际质量问题、整改负责人、唯一质量问题复核负责人、整改时限和通过标准并完成端到端运营验收;复验检查可关联但不把违规抽样未命中当成自动通过;通知升级、通用工单集成、自动修复、跨资产趋势和根因分析不在 WP-08 |
 | WP-09 | 工程完成,待企业运行关系与专家验收 | 四类运行事件按来源身份不可变幂等接入;资产、事件和质量问题的八类有向证据关系;三跳、100 节点、200 边的有界关系图;只沿持久化上游证据关系返回根因候选和路径;证据不足时明确无法确认;编辑者导入与查看者只读分离;OpenAPI 195 项;真实 PostgreSQL 定向验证 | 需要企业接入告警、故障、维修和停机事件,确认关系方向、时间窗口和专家判定标准;当前不融合通用血缘与变更事件,不覆盖产品、报表、Agent 和业务域影响,不生成 AI 修复建议、自动修复、预测性维护或维修计划 |
 | WP-10 | 工程完成,待企业授权、模型与黄金集验收 | canonical 设备资产及四类运行事件进入现有混合检索;按设备名称、平台 UID、授权源 ID、位置、组织、责任人和事件检索;数据源业务域 SQL 预过滤与融合后二次授权;安全设备详情;授权证据问答、引用内容、模型不可用和证据不足拒答;最小化问题哈希审计;管理员源范围和审计工作台;十项版本化验收模板;OpenAPI 211 项;真实 PostgreSQL 与页面链路定向验证 | 需要企业配置真实设备源业务域范围,将十项模板绑定真实设备、故障和越权案例,在合规模型环境完成设备专家复核;未完成前不得声称企业验收或生产 K6;不建设 NL2SQL、在线分析、BI 开发、自动根因结论、自动修复或直接 LightRAG 回答 |
+| WP-11 | 工程完成,待企业口径、阈值与真实数据验收 | 实时计算台账完整率、责任覆盖率、实体映射率、问题闭环率和问题复发率;分子、分母、公式及零分母状态明确;按数据源业务域在 SQL 聚合前授权;跨域合并双端可见门禁;五项指标均可下钻到安全明细;固定只读运营看板;OpenAPI 213 项;真实 PostgreSQL 定向验证 | 需要企业确认必填字段、指标阈值、业务域范围和验收样本;当前只覆盖设备治理域,不保存手工快照或历史趋势,不建设综合评分、排名、成熟度、责任人绩效、通用 BI、NL2SQL 或分析开发能力;GOV-12、GOV-13 仍为规划中,PLT-03、PLT-04 成熟度不因固定看板提升 |
 
 ## 7. 12 周执行计划
 

+ 2 - 2
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -478,8 +478,8 @@ DataOps Platform 当前已经具备较完整的“治理对象 → 知识服务
 | GOV-07 | 治理组织 / 跨域协同 | 跨业务域资产、标准、本体和权限变更联合评审 | 规划中 |
 | GOV-08 | 治理组织 / 中央策略 | 中央治理政策、模板和强制规则向业务域下发 | 规划中 |
 | GOV-09 | 治理运营 / 治理任务 | 治理任务、责任人、截止时间、状态和结果 | 部分建设 |
-| GOV-10 | 治理运营 / 业务域评分 | 按完整性、责任覆盖、质量和整改结果评分 | 规划中 |
-| GOV-11 | 治理运营 / 责任考核 | 质量问题闭环率、逾期率、复发率和责任人绩效 | 规划中 |
+| GOV-10 | 治理运营 / 业务域评分 | 按完整性、责任覆盖、质量和整改结果评分 | 设备域五项实时指标与授权明细工程完成;综合评分、阈值和跨域验收待建设 |
+| GOV-11 | 治理运营 / 责任考核 | 质量问题闭环率、逾期率、复发率和责任人绩效 | 闭环率、复发率及责任/逾期明细工程完成;责任人绩效未建设 |
 | GOV-12 | 治理运营 / 排名看板 | 业务域治理看板、趋势和内部对标 | 规划中 |
 | GOV-13 | 治理运营 / 成熟度评估 | 治理成熟度模型、制度执行检查和管理层驾驶舱 | 规划中 |
 | GOV-14 | 治理运营 / 设备责任 | 设备资产管理员作为设备本体、映射和故障分类审批人 | 已建设 |

+ 28 - 0
docs/architecture/DATA_MODEL.md

@@ -202,6 +202,33 @@ UID、名称、授权源 ID、位置、组织、责任人和授权事件类型/
 `knowledge_query_audits`,但只保存规范化问题的 SHA-256、身份/角色/授权域、检索模式、
 检索器计数、引用知识点身份、降级组件、关联 ID 和耗时。
 
+## 4.1 WP11 治理运营指标查询投影
+
+WP11 不新增指标快照表,也不允许人工填报聚合结果。五项指标均在请求时从 PostgreSQL
+canonical 数据计算,并返回固定定义、分子、分母、比率和可用状态:
+
+| 指标 | 分子 | 分母 |
+|---|---|---|
+| 台账完整率 | 名称、位置、组织、责任人非空,且至少存在一个授权有效来源映射的在用设备 | 当前用户可见在用设备 |
+| 责任覆盖率 | 责任人非空的在用设备 | 当前用户可见在用设备 |
+| 实体映射率 | 参与有效且未回滚实体合并的在用设备 | 当前用户可见在用设备 |
+| 问题闭环率 | 状态为 `closed` 的可见质量问题 | 当前用户可见质量问题 |
+| 问题复发率 | `occurrence_number > 1` 的可见质量问题 | 当前用户可见质量问题 |
+
+分母为零时返回 `rate = null` 和 `status = no_data`,不能用 0% 替代暂无数据。比率最多保留
+六位小数。指标明细只返回设备 UID/名称/类型/位置/组织/责任人、授权来源编码、缺失字段、
+有效合并 UID,以及质量问题编号、设备、规则、字段、优先级、状态、发生次数、责任人 UID、
+期限、关闭时间和实时逾期标记;不返回 `device_assets.attributes`、质量问题 `message`/
+`evidence`、数据源 `config`/`permission_scope` 或凭据。
+
+授权沿用知识检索的服务端身份和业务域授权上下文。普通用户只可见映射到
+`authorized_sources` 的在用设备;实体合并只有 canonical/member 两端均可见且没有回滚记录
+时才能计入;质量问题优先按其明确 `source_uid` 或 `source_mapping_uid` 授权。管理员可统计
+全部在用设备和问题。聚合与明细均在 SQL 查询阶段先授权,不依赖前端过滤。
+
+该投影只完成设备域固定运营视图,不代表综合业务域评分、员工绩效、排名趋势、成熟度驾驶舱、
+通用 BI、NL2SQL 或分析开发平台已经建设。
+
 ## 5. 所有权与删除规则
 
 - PostgreSQL 是身份、权限、映射、任务状态、布局和一致性事件的源真相。
@@ -216,6 +243,7 @@ UID、名称、授权源 ID、位置、组织、责任人和授权事件类型/
 - 质量问题、整改轮次和处理时间线以 PostgreSQL 为源真相;问题保存 WP-07 违规的安全证据快照,状态变更采用乐观锁,关闭必须由 `quality_issue/DEVICE_QUALITY_ISSUES` 唯一负责的设备资产管理员独立复核。逾期由期限和未关闭状态实时计算,复发由规则、资产和字段的确定性身份统计,不等同于自动根因结论。
 - 设备运行事件和有向证据关系以 PostgreSQL 为源真相;关系图是最多三跳、100 个节点和 200 条边的可重建查询投影。根因分析只沿 `indicates`、`triggered` 和 `evidences` 上游关系返回候选及证据路径;没有持久化路径时必须返回“证据不足,无法确认根因”,结果不触发自动修复或维修计划。
 - 设备知识检索直接读取授权后的 PostgreSQL canonical 资产、来源映射和运行事件;不建立第二份设备主数据,不读取来源配置和事件原始证据。问答不能替代 WP-09 的证据路径或设备专家根因结论。
+- 治理运营指标是 PostgreSQL canonical 数据的实时只读查询投影;不保存人工覆盖值。指标汇总与明细必须使用同一业务域授权边界,跨域合并只有两端均可见时才能计入非管理员结果。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 41 - 1
docs/architecture/OPENAPI.yaml

@@ -3,7 +3,7 @@ info:
   title: "DataOps Platform API(当前代码基线)"
   version: "2026-07-16"
   description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"
-x-route-count: 211
+x-route-count: 213
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -2976,6 +2976,46 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/governance-metrics/details":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_governance_metric_details_get
+      summary: "get governance metric details"
+      x-source: "app/api/data_development/routes.py"
+      x-response-fields: [metric, state, records, page, page_size, total]
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/governance-metrics/summary":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_governance_metric_summary_get
+      summary: "get governance metric summary"
+      x-source: "app/api/data_development/routes.py"
+      x-response-fields: [scope, metrics]
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/development/v1/ingestion-jobs":
     get:
       tags: [data_development]

+ 117 - 0
docs/superpowers/plans/2026-07-30-wp11-governance-operational-metrics.md

@@ -0,0 +1,117 @@
+# WP11 Governance Operational Metrics Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
+
+**Goal:** Deliver five real-time, authorization-aware governance metrics with safe metric-to-detail traceability for the device governance domain.
+
+**Architecture:** PostgreSQL device assets, authorized source mappings, entity merge events, and quality issues remain authoritative. A read-only metric service calculates each numerator and denominator from current canonical data, applies business-domain authorization in SQL before aggregation or detail retrieval, and exposes explicit definitions plus bounded safe details. The Vue 2 governance dashboard consumes only these APIs and performs no manual metric entry or client-side security filtering.
+
+**Tech Stack:** Flask, SQLAlchemy/PostgreSQL, existing identity and business-domain authorization services, Vue 2/Vuetify, pytest, Node test runner.
+
+---
+
+## Scope and constraints
+
+- Calculate asset completeness, responsibility coverage, entity mapping, issue closure, and issue recurrence for the caller's authorized device scope.
+- Define asset completeness as active assets with name, location, organization, responsible person, and at least one authorized active-source mapping divided by active visible assets.
+- Define responsibility coverage as active assets with a non-empty responsible person divided by active visible assets.
+- Define entity mapping as active visible assets participating on either side of an active, non-rolled-back entity merge divided by active visible assets. Non-admin users may count a merge only when both endpoints are visible.
+- Define issue closure as closed visible issues divided by all visible issues; define issue recurrence as visible issues with `occurrence_number > 1` divided by all visible issues.
+- Return `rate=null` and `status=no_data` when a denominator is zero; never present zero as a substitute for unavailable data.
+- Apply source business-domain authorization in SQL before aggregation and detail retrieval. Empty source scopes are admin-only.
+- Return safe operational fields only. Do not expose asset attribute payloads, issue evidence or message text, source credentials, source configuration, or permission-scope payloads.
+- Provide read-only dashboard and detail traceability. Do not add manual metric entry, employee performance scoring, rankings, trends, maturity assessment, generic BI, NL2SQL, or analysis-development capabilities.
+- Treat WP11 as device-domain engineering completion only. Enterprise metric thresholds, cross-domain rollout, historical trend baselines, and real-data acceptance remain separate gates.
+
+### Task 1: Metric contract and deterministic calculation
+
+**Files:**
+- Create: `app/core/data_research/governance_metrics.py`
+- Create: `tests/data_research/test_governance_metrics.py`
+
+- [x] Write failing tests for metric definitions, numerator/denominator calculation, six-decimal rounding, zero-denominator `no_data`, supported state filters, page boundaries, and invalid metric/state rejection.
+- [x] Run only the new service test and confirm failure is caused by the absent metric service.
+- [x] Implement immutable access, summary, and detail contracts plus `GovernanceMetricsService`.
+- [x] Keep calculation orchestration independent from Flask and SQLAlchemy session globals.
+- [x] Re-run only the service test until green.
+
+### Task 2: PostgreSQL authorization and safe detail traceability
+
+**Files:**
+- Create: `app/core/data_research/governance_metric_repository.py`
+- Create: `tests/integration/test_governance_metrics_postgres.py`
+
+- [x] Write a failing PostgreSQL test with authorized, unauthorized, and unscoped device sources; active and retired assets; rolled-back and active merges; and first/recurrent/open/closed issues.
+- [x] Prove admin aggregation includes all active device data while a domain viewer sees only authorized sources, assets, merge endpoints, and issues.
+- [x] Prove a cross-domain merge is excluded for a non-admin unless both endpoints are visible.
+- [x] Prove details contain only the documented safe fields and never asset attributes, issue evidence/messages, source configuration, credentials, or permission-scope payloads.
+- [x] Implement authorized-source, visible-asset, active-merge, and visible-issue query boundaries in SQL.
+- [x] Implement deterministic metric summaries and paginated state-specific details.
+- [x] Run only the PostgreSQL metric integration test until green.
+
+### Task 3: Read-only API and permission boundary
+
+**Files:**
+- Modify: `app/api/data_development/routes.py`
+- Modify: `app/core/system/permissions.py`
+- Create: `tests/data_research/test_governance_metrics_api.py`
+- Modify: `tests/test_permission_matrix.py`
+
+- [x] Write failing API tests for summary, metric/state detail, invalid input, access-context conversion, and repository error handling.
+- [x] Write a failing permission test proving both endpoints require `governance:read` and remain covered by default deny.
+- [x] Build the metric access context from the existing canonical identity/business-domain authorization service.
+- [x] Add `GET /api/development/v1/governance-metrics/summary`.
+- [x] Add `GET /api/development/v1/governance-metrics/details` with metric, state, page, and bounded page-size parameters.
+- [x] Return validation errors as 400 and unexpected failures through the existing safe API error contract.
+- [x] Run only the API and permission tests until green.
+
+### Task 4: Governance operations dashboard
+
+**Files:**
+- Create: `frontend/src/views/dataGovernance/development/governanceMetrics.vue`
+- Create: `frontend/src/views/dataGovernance/development/governanceMetricsModel.js`
+- Create: `frontend/tests/governance-metrics-model.test.mjs`
+- Create: `tests/data_research/test_governance_metrics_frontend_contract.py`
+- Modify: `frontend/src/api/dataDevelopment.js`
+- Modify: `frontend/src/router/routes.js`
+- Modify: `frontend/src/views/dataGovernance/development/index.vue`
+
+- [x] Write failing model tests for percentage formatting, `no_data`, metric/state labels, safe detail columns, and authorization-scope presentation.
+- [x] Write a failing frontend contract test for all five metric cards, explicit formulas, numerator/denominator, real-time/non-manual wording, authorized-scope wording, and detail traceability.
+- [x] Add API client methods for summary and details.
+- [x] Add a hidden routed dashboard page and a visible development-center entry.
+- [x] Render five cards with definition, numerator, denominator, rate, and state-specific detail actions.
+- [x] Render paginated safe details without client-side authorization assumptions or raw evidence payloads.
+- [x] Use the existing snackbar interface for load failures and keep `no_data` distinct from 0%.
+- [x] Run only the Node model test, frontend contract test, and ESLint on changed frontend files.
+
+### Task 5: Documentation, OpenAPI, and release-copy parity
+
+**Files:**
+- Modify: `docs/FUNCTION_MODULE_CENSUS_20260726.md`
+- Modify: `docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md`
+- Modify: `docs/architecture/DATA_MODEL.md`
+- Modify: `docs/architecture/OPENAPI.yaml`
+- Modify: `tests/test_architecture_artifacts.py`
+- Mirror changed backend files under: `deployment/app/`
+
+- [x] Document all five formulas, authorization behavior, zero-denominator behavior, safe detail boundaries, and excluded capabilities.
+- [x] Mark WP11 device-domain engineering maturity separately from enterprise thresholds and real-data acceptance.
+- [x] Update GOV-10 and GOV-11 without claiming employee performance, ranking, trend, maturity, or general BI completion.
+- [x] Keep PLT-03 and PLT-04 maturity unchanged because WP11 adds a fixed operational view rather than an editable/persisted workbench.
+- [x] Regenerate OpenAPI and assert both read-only governance-metric endpoints are represented.
+- [x] Copy every changed `app/` backend file to `deployment/app/` and verify byte parity.
+
+### Task 6: Targeted validation, browser acceptance, and local branch commit
+
+**Files:**
+- Test only WP11-related files and directly affected permission/architecture contracts.
+
+- [x] Run WP11 service, API, PostgreSQL integration, frontend contract, permission, and architecture tests only.
+- [x] Run Ruff only on changed Python files.
+- [x] Run ESLint only on changed frontend files and run the governance metric Node model test.
+- [x] Build the frontend bundle because WP11 adds a routed page.
+- [x] Rebuild only the local backend/frontend services needed for WP11 browser validation.
+- [x] In the browser, verify all five cards, formula/count/rate display, authorized-scope wording, state-specific detail traceability, safe detail fields, navigation, and zero console errors; verify `no_data` presentation through the model/API contract tests.
+- [x] Confirm no ranking, employee scoring, manual metric input, trend, or analysis-development entry was introduced.
+- [x] Commit the verified WP11 change on `codex/dataops-phase1-equipment-governance`; do not push or deploy production.

+ 8 - 0
frontend/src/api/dataDevelopment.js

@@ -7,6 +7,7 @@ const DEVICE_SEMANTIC_BASE = '/development/v1/device-semantics'
 const DEVICE_ENTITY_BASE = '/development/v1/device-entities'
 const DEVICE_QUALITY_BASE = '/development/v1/device-quality'
 const DEVICE_OBSERVABILITY_BASE = '/development/v1/device-observability'
+const GOVERNANCE_METRIC_BASE = '/development/v1/governance-metrics'
 
 export const createIngestionJob = params => http.post(BASE, params)
 export const getIngestionJobs = params => http.get(BASE, params)
@@ -144,6 +145,13 @@ export const rollbackDeviceEntityMerge = (uid, params) => http.post(
 export const getDeviceEntityRollbacks = uid => http.get(
   `${DEVICE_ENTITY_BASE}/merges/${uid}/rollbacks`
 )
+export const getGovernanceMetricSummary = () => http.get(
+  `${GOVERNANCE_METRIC_BASE}/summary`
+)
+export const getGovernanceMetricDetails = params => http.get(
+  `${GOVERNANCE_METRIC_BASE}/details`,
+  params
+)
 export const getDeviceQualityProfile = () => http.get(
   `${DEVICE_QUALITY_BASE}/profile`
 )

+ 12 - 0
frontend/src/router/routes.js

@@ -264,6 +264,18 @@ export default {
           name: 'deviceObservability',
           alwaysShow: 0
         },
+        {
+          hidden: 1,
+          type: 1,
+          title: '治理运营指标',
+          path: '/data-governance/development/governance-metrics',
+          children: [],
+          label: '治理运营指标',
+          component: 'dataGovernance/development/governanceMetrics',
+          meta: { roles: ['viewer', 'editor', 'admin'], title: '治理运营指标', readOnly: 'viewer' },
+          name: 'governanceMetrics',
+          alwaysShow: 0
+        },
         {
           hidden: 1,
           type: 1,

+ 218 - 0
frontend/src/views/dataGovernance/development/governanceMetrics.vue

@@ -0,0 +1,218 @@
+<template>
+  <div class="pa-6 governance-metrics">
+    <div class="d-flex flex-wrap align-start justify-space-between mb-5">
+      <div>
+        <h1 class="text-h4 mb-2">治理运营指标</h1>
+        <p class="text--secondary mb-0">
+          从设备台账、实体映射和质量整改数据实时计算,非手工填报。
+        </p>
+      </div>
+      <v-btn color="primary" outlined :loading="loading" class="mt-2" @click="loadSummary">
+        <v-icon left>mdi-refresh</v-icon>
+        刷新指标
+      </v-btn>
+    </div>
+
+    <v-alert type="info" outlined class="mb-5">
+      <strong>{{ scopeLabel }}</strong>
+      <span class="ml-2">授权在服务端聚合与明细查询前完成;暂无数据不等于 0%。</span>
+    </v-alert>
+
+    <v-row>
+      <v-col
+        v-for="metric in metrics"
+        :key="metric.code"
+        cols="12"
+        md="6"
+        lg="4"
+      >
+        <v-card outlined height="100%" class="metric-card">
+          <v-card-title class="align-start">
+            <v-avatar :color="view(metric).color" size="42" class="mr-3">
+              <v-icon dark>{{ view(metric).icon }}</v-icon>
+            </v-avatar>
+            <div>
+              <div class="subtitle-1 font-weight-bold">{{ view(metric).name }}</div>
+              <div class="metric-rate">{{ formatMetricRate(metric) }}</div>
+            </div>
+          </v-card-title>
+          <v-card-text>
+            <p class="metric-definition">{{ metric.definition || view(metric).definition }}</p>
+            <div class="d-flex justify-space-between count-strip">
+              <span>分子 <strong>{{ metric.numerator }}</strong></span>
+              <span>分母 <strong>{{ metric.denominator }}</strong></span>
+            </div>
+            <v-chip
+              v-if="metric.status === 'no_data'"
+              small
+              outlined
+              color="blue-grey"
+              class="mt-3"
+            >
+              暂无数据
+            </v-chip>
+          </v-card-text>
+          <v-divider />
+          <v-card-actions class="flex-wrap">
+            <v-btn
+              v-for="state in metric.states"
+              :key="state"
+              text
+              small
+              :color="stateView(state).color"
+              @click="openDetails(metric, state)"
+            >
+              查看明细 · {{ stateView(state).label }}
+            </v-btn>
+          </v-card-actions>
+        </v-card>
+      </v-col>
+    </v-row>
+
+    <v-alert v-if="!loading && !metrics.length" type="info" outlined class="mt-5">
+      暂无数据。当前授权范围内还没有可计算的治理对象。
+    </v-alert>
+
+    <v-dialog v-model="detailDialog" max-width="1280">
+      <v-card>
+        <v-card-title class="d-flex justify-space-between">
+          <span>{{ selectedMetricName }} · {{ selectedStateLabel }}</span>
+          <v-btn icon @click="detailDialog = false"><v-icon>mdi-close</v-icon></v-btn>
+        </v-card-title>
+        <v-card-subtitle>
+          指标到明细追溯仅展示授权后的安全运营字段。
+        </v-card-subtitle>
+        <v-data-table
+          :headers="headers"
+          :items="details"
+          :loading="loadingDetails"
+          :server-items-length="detailTotal"
+          :page.sync="detailPage"
+          :items-per-page="20"
+          class="elevation-0"
+          @update:page="loadDetails"
+        >
+          <template v-slot:[`item.source_codes`]="{ item }">
+            {{ (item.source_codes || []).join('、') || '-' }}
+          </template>
+          <template v-slot:[`item.missing_fields`]="{ item }">
+            {{ (item.missing_fields || []).join('、') || '-' }}
+          </template>
+          <template v-slot:[`item.due_at`]="{ item }">{{ formatTime(item.due_at) }}</template>
+          <template v-slot:[`item.closed_at`]="{ item }">{{ formatTime(item.closed_at) }}</template>
+          <template v-slot:[`item.overdue`]="{ item }">
+            <v-chip small outlined :color="item.overdue ? 'error' : 'success'">
+              {{ item.overdue ? '是' : '否' }}
+            </v-chip>
+          </template>
+          <template #no-data>
+            <div class="py-8 text--secondary">当前状态下暂无可追溯明细。</div>
+          </template>
+        </v-data-table>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  getGovernanceMetricDetails,
+  getGovernanceMetricSummary
+} from '@/api/dataDevelopment'
+import {
+  detailHeaders,
+  formatMetricRate,
+  metricPresentation,
+  scopePresentation,
+  statePresentation
+} from './governanceMetricsModel'
+
+export default {
+  name: 'GovernanceMetrics',
+  data: () => ({
+    loading: false,
+    loadingDetails: false,
+    metrics: [],
+    scope: {},
+    detailDialog: false,
+    selectedMetric: null,
+    selectedState: null,
+    details: [],
+    detailTotal: 0,
+    detailPage: 1
+  }),
+  computed: {
+    scopeLabel () {
+      return scopePresentation(this.scope)
+    },
+    headers () {
+      return detailHeaders(this.selectedMetric?.code)
+    },
+    selectedMetricName () {
+      return metricPresentation(this.selectedMetric?.code).name
+    },
+    selectedStateLabel () {
+      return statePresentation(this.selectedState).label
+    }
+  },
+  created () {
+    this.loadSummary()
+  },
+  methods: {
+    formatMetricRate,
+    view (metric) {
+      return metricPresentation(metric.code)
+    },
+    stateView (state) {
+      return statePresentation(state)
+    },
+    formatTime (value) {
+      return value ? new Date(value).toLocaleString() : '-'
+    },
+    async loadSummary () {
+      this.loading = true
+      try {
+        const response = await getGovernanceMetricSummary()
+        this.metrics = response.data.metrics || []
+        this.scope = response.data.scope || {}
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '治理运营指标加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    openDetails (metric, state) {
+      this.selectedMetric = metric
+      this.selectedState = state
+      this.detailPage = 1
+      this.detailDialog = true
+      this.loadDetails()
+    },
+    async loadDetails () {
+      if (!this.selectedMetric || !this.selectedState) return
+      this.loadingDetails = true
+      try {
+        const response = await getGovernanceMetricDetails({
+          metric: this.selectedMetric.code,
+          state: this.selectedState,
+          page: this.detailPage,
+          page_size: 20
+        })
+        this.details = response.data.records || []
+        this.detailTotal = Number(response.data.total || 0)
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '治理指标明细加载失败')
+      } finally {
+        this.loadingDetails = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.metric-card { border-top: 3px solid #3157d5; }
+.metric-rate { font-size: 2rem; line-height: 1.2; font-weight: 700; color: #172554; }
+.metric-definition { min-height: 48px; line-height: 1.55; }
+.count-strip { padding: 12px 14px; border-radius: 8px; background: #f5f7fb; }
+</style>

+ 102 - 0
frontend/src/views/dataGovernance/development/governanceMetricsModel.js

@@ -0,0 +1,102 @@
+const METRICS = {
+  asset_completeness: {
+    name: '台账完整率',
+    icon: 'mdi-clipboard-text-search-outline',
+    color: 'indigo',
+    definition: '名称、位置、组织、责任人及授权有效来源映射均完整的在用设备占可见在用设备的比例'
+  },
+  responsibility_coverage: {
+    name: '责任覆盖率',
+    icon: 'mdi-account-check-outline',
+    color: 'teal',
+    definition: '已明确责任人的在用设备占可见在用设备的比例'
+  },
+  entity_mapping: {
+    name: '实体映射率',
+    icon: 'mdi-vector-link',
+    color: 'deep-purple',
+    definition: '参与有效且未回滚实体合并的在用设备占可见在用设备的比例'
+  },
+  issue_closure: {
+    name: '问题闭环率',
+    icon: 'mdi-check-decagram-outline',
+    color: 'green darken-1',
+    definition: '已关闭质量问题占可见质量问题总数的比例'
+  },
+  issue_recurrence: {
+    name: '问题复发率',
+    icon: 'mdi-backup-restore',
+    color: 'deep-orange',
+    definition: '发生次数大于一次的质量问题占可见质量问题总数的比例'
+  }
+}
+
+const STATES = {
+  complete: { label: '完整设备', color: 'success' },
+  incomplete: { label: '不完整设备', color: 'warning' },
+  covered: { label: '已覆盖设备', color: 'success' },
+  uncovered: { label: '未覆盖设备', color: 'warning' },
+  mapped: { label: '已映射设备', color: 'success' },
+  unmapped: { label: '未映射设备', color: 'warning' },
+  closed: { label: '已闭环问题', color: 'success' },
+  open: { label: '未闭环问题', color: 'warning' },
+  recurrent: { label: '复发问题', color: 'error' },
+  first_occurrence: { label: '首次发生问题', color: 'info' }
+}
+
+const ASSET_HEADERS = [
+  { text: '设备', value: 'asset_name', sortable: false },
+  { text: '类型', value: 'asset_type', sortable: false },
+  { text: '位置', value: 'location', sortable: false },
+  { text: '组织', value: 'organization', sortable: false },
+  { text: '责任人', value: 'responsible_person', sortable: false },
+  { text: '授权来源编码', value: 'source_codes', sortable: false },
+  { text: '缺失项', value: 'missing_fields', sortable: false }
+]
+
+const ISSUE_HEADERS = [
+  { text: '问题编号', value: 'issue_code', sortable: false },
+  { text: '设备', value: 'asset_name', sortable: false },
+  { text: '规则', value: 'rule_code', sortable: false },
+  { text: '字段', value: 'field_name', sortable: false },
+  { text: '优先级', value: 'priority', sortable: false },
+  { text: '状态', value: 'status', sortable: false },
+  { text: '发生次数', value: 'occurrence_number', sortable: false },
+  { text: '责任人 UID', value: 'assignee_uid', sortable: false },
+  { text: '截止时间', value: 'due_at', sortable: false },
+  { text: '关闭时间', value: 'closed_at', sortable: false },
+  { text: '已逾期', value: 'overdue', sortable: false }
+]
+
+export function formatMetricRate (metric = {}) {
+  if (metric.status === 'no_data' || metric.rate === null || metric.rate === undefined) {
+    return '暂无数据'
+  }
+  return `${(Number(metric.rate) * 100).toFixed(1)}%`
+}
+
+export function metricPresentation (code) {
+  return METRICS[code] || {
+    name: code || '治理指标',
+    icon: 'mdi-chart-box-outline',
+    color: 'blue-grey',
+    definition: ''
+  }
+}
+
+export function statePresentation (state) {
+  return STATES[state] || { label: state || '明细', color: 'blue-grey' }
+}
+
+export function scopePresentation (scope = {}) {
+  if (scope.global_access) return '全部可访问范围'
+  const count = (scope.business_domain_uids || []).length
+  if (!count) return '当前没有已授权业务域'
+  return `按服务端业务域授权过滤(${count} 个业务域)`
+}
+
+export function detailHeaders (metric) {
+  return metric === 'issue_closure' || metric === 'issue_recurrence'
+    ? ISSUE_HEADERS
+    : ASSET_HEADERS
+}

+ 1 - 0
frontend/src/views/dataGovernance/development/index.vue

@@ -28,6 +28,7 @@ export default {
       { title: '实体匹配', description: '跨来源匹配候选、审核、非破坏性合并与回滚', icon: 'mdi-vector-link', path: '/data-governance/development/entity-resolution' },
       { title: '设备质量', description: '规则版本、质量检查、违规样本与资产评分', icon: 'mdi-shield-check-outline', path: '/data-governance/development/device-quality' },
       { title: '设备关系与根因', description: '追溯设备运行事件关系,查看有证据约束的根因候选', icon: 'mdi-vector-polyline', path: '/data-governance/development/device-observability' },
+      { title: '治理运营指标', description: '实时查看完整率、责任覆盖、实体映射与质量问题闭环', icon: 'mdi-chart-box-outline', path: '/data-governance/development/governance-metrics' },
       { title: '本体中心', description: '跨业务域本体定义、校验、发布与回滚', icon: 'mdi-graph-outline', path: '/data-governance/ontology' }
     ]
   })

+ 76 - 0
frontend/tests/governance-metrics-model.test.mjs

@@ -0,0 +1,76 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import {
+  detailHeaders,
+  formatMetricRate,
+  metricPresentation,
+  scopePresentation,
+  statePresentation
+} from '../src/views/dataGovernance/development/governanceMetricsModel.js'
+
+test('formats available percentages and keeps no data distinct from zero', () => {
+  assert.equal(formatMetricRate({ rate: 0, status: 'available' }), '0.0%')
+  assert.equal(formatMetricRate({ rate: 0.666667, status: 'available' }), '66.7%')
+  assert.equal(formatMetricRate({ rate: null, status: 'no_data' }), '暂无数据')
+})
+
+test('presents the five fixed governance metrics and their trace states', () => {
+  assert.equal(metricPresentation('asset_completeness').name, '台账完整率')
+  assert.equal(metricPresentation('responsibility_coverage').name, '责任覆盖率')
+  assert.equal(metricPresentation('entity_mapping').name, '实体映射率')
+  assert.equal(metricPresentation('issue_closure').name, '问题闭环率')
+  assert.equal(metricPresentation('issue_recurrence').name, '问题复发率')
+  assert.equal(statePresentation('incomplete').label, '不完整设备')
+  assert.equal(statePresentation('recurrent').label, '复发问题')
+})
+
+test('makes global and business-domain authorization scopes explicit', () => {
+  assert.equal(
+    scopePresentation({ global_access: true, business_domain_uids: [] }),
+    '全部可访问范围'
+  )
+  assert.equal(
+    scopePresentation({
+      global_access: false,
+      business_domain_uids: ['domain-a', 'domain-b']
+    }),
+    '按服务端业务域授权过滤(2 个业务域)'
+  )
+  assert.equal(
+    scopePresentation({ global_access: false, business_domain_uids: [] }),
+    '当前没有已授权业务域'
+  )
+})
+
+test('detail columns are bounded to safe asset and issue fields', () => {
+  const assetValues = detailHeaders('asset_completeness').map(item => item.value)
+  const issueValues = detailHeaders('issue_recurrence').map(item => item.value)
+
+  assert.deepEqual(assetValues, [
+    'asset_name',
+    'asset_type',
+    'location',
+    'organization',
+    'responsible_person',
+    'source_codes',
+    'missing_fields'
+  ])
+  assert.deepEqual(issueValues, [
+    'issue_code',
+    'asset_name',
+    'rule_code',
+    'field_name',
+    'priority',
+    'status',
+    'occurrence_number',
+    'assignee_uid',
+    'due_at',
+    'closed_at',
+    'overdue'
+  ])
+  for (const forbidden of ['attributes', 'evidence', 'message', 'config', 'permission_scope']) {
+    assert.ok(!assetValues.includes(forbidden))
+    assert.ok(!issueValues.includes(forbidden))
+  }
+})

+ 12 - 0
scripts/generate_openapi.py

@@ -28,6 +28,18 @@ PREFIXES = {
 
 SHORTHAND_METHODS = {"get", "post", "put", "patch", "delete"}
 RESPONSE_FIELDS = {
+    ("data_development", "summary"): [
+        "scope",
+        "metrics",
+    ],
+    ("data_development", "details"): [
+        "metric",
+        "state",
+        "records",
+        "page",
+        "page_size",
+        "total",
+    ],
     ("knowledge_base", "ask"): [
         "query_id",
         "mode",

+ 164 - 0
tests/data_research/test_governance_metrics.py

@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import pytest
+
+
+class MemoryGovernanceMetricRepository:
+    def __init__(self):
+        self.detail_calls = []
+
+    def summary_counts(self, access):
+        assert access.business_domain_uids == ("domain-a", "domain-b")
+        return {
+            "asset_completeness": (2, 3),
+            "responsibility_coverage": (3, 3),
+            "entity_mapping": (1, 3),
+            "issue_closure": (1, 2),
+            "issue_recurrence": (0, 0),
+        }
+
+    def list_details(self, access, *, metric, state, page, page_size):
+        self.detail_calls.append(
+            (access, metric, state, page, page_size)
+        )
+        return (
+            [
+                {
+                    "asset_uid": "asset-1",
+                    "asset_name": "冷却泵",
+                    "missing_fields": ["organization"],
+                }
+            ],
+            1,
+        )
+
+
+def test_summary_exposes_fixed_definitions_counts_and_rates():
+    from app.core.data_research.governance_metrics import (
+        GovernanceMetricAccess,
+        GovernanceMetricsService,
+    )
+
+    service = GovernanceMetricsService(MemoryGovernanceMetricRepository())
+    result = service.summary(
+        GovernanceMetricAccess(
+            global_access=False,
+            business_domain_uids=("domain-b", "domain-a", "domain-a"),
+        )
+    )
+
+    assert result["scope"] == {
+        "global_access": False,
+        "business_domain_uids": ["domain-a", "domain-b"],
+    }
+    assert [item["code"] for item in result["metrics"]] == [
+        "asset_completeness",
+        "responsibility_coverage",
+        "entity_mapping",
+        "issue_closure",
+        "issue_recurrence",
+    ]
+    assert result["metrics"][0] == {
+        "code": "asset_completeness",
+        "name": "台账完整率",
+        "definition": (
+            "名称、位置、组织、责任人及授权有效来源映射均完整的在用设备"
+            "占可见在用设备的比例"
+        ),
+        "numerator": 2,
+        "denominator": 3,
+        "rate": 0.666667,
+        "status": "available",
+        "states": ["complete", "incomplete"],
+    }
+    assert result["metrics"][1]["rate"] == 1.0
+    assert result["metrics"][2]["rate"] == 0.333333
+    assert result["metrics"][3]["rate"] == 0.5
+    assert result["metrics"][4]["rate"] is None
+    assert result["metrics"][4]["status"] == "no_data"
+
+
+def test_detail_validates_metric_state_and_pagination():
+    from app.core.data_research.errors import GovernanceMetricInvalid
+    from app.core.data_research.governance_metrics import (
+        GovernanceMetricAccess,
+        GovernanceMetricsService,
+    )
+
+    repository = MemoryGovernanceMetricRepository()
+    service = GovernanceMetricsService(repository)
+    access = GovernanceMetricAccess(global_access=True)
+
+    result = service.details(
+        access,
+        metric="asset_completeness",
+        state="incomplete",
+        page="2",
+        page_size="25",
+    )
+
+    assert result == {
+        "metric": {
+            "code": "asset_completeness",
+            "name": "台账完整率",
+            "definition": (
+                "名称、位置、组织、责任人及授权有效来源映射均完整的在用设备"
+                "占可见在用设备的比例"
+            ),
+        },
+        "state": "incomplete",
+        "records": [
+            {
+                "asset_uid": "asset-1",
+                "asset_name": "冷却泵",
+                "missing_fields": ["organization"],
+            }
+        ],
+        "page": 2,
+        "page_size": 25,
+        "total": 1,
+    }
+    assert repository.detail_calls[-1][1:] == (
+        "asset_completeness",
+        "incomplete",
+        2,
+        25,
+    )
+
+    with pytest.raises(GovernanceMetricInvalid, match="unsupported metric"):
+        service.details(access, metric="ranking", state="top")
+    with pytest.raises(GovernanceMetricInvalid, match="unsupported state"):
+        service.details(
+            access,
+            metric="asset_completeness",
+            state="mapped",
+        )
+    with pytest.raises(GovernanceMetricInvalid, match="page must"):
+        service.details(
+            access,
+            metric="asset_completeness",
+            state="complete",
+            page=0,
+        )
+    with pytest.raises(GovernanceMetricInvalid, match="page_size must"):
+        service.details(
+            access,
+            metric="asset_completeness",
+            state="complete",
+            page_size=101,
+        )
+
+
+def test_access_contract_is_immutable_and_normalizes_domains():
+    from dataclasses import FrozenInstanceError
+
+    from app.core.data_research.governance_metrics import GovernanceMetricAccess
+
+    access = GovernanceMetricAccess(
+        global_access=False,
+        business_domain_uids=(" domain-b ", "", "domain-a", "domain-a"),
+    )
+
+    assert access.business_domain_uids == ("domain-a", "domain-b")
+    with pytest.raises(FrozenInstanceError):
+        access.global_access = True

+ 173 - 0
tests/data_research/test_governance_metrics_api.py

@@ -0,0 +1,173 @@
+from __future__ import annotations
+
+
+class FakeGovernanceMetricsService:
+    def __init__(self):
+        self.calls = []
+
+    def summary(self, access):
+        self.calls.append(("summary", access))
+        return {
+            "scope": {
+                "global_access": access.global_access,
+                "business_domain_uids": list(access.business_domain_uids),
+            },
+            "metrics": [
+                {
+                    "code": "asset_completeness",
+                    "name": "台账完整率",
+                    "definition": "固定口径",
+                    "numerator": 2,
+                    "denominator": 3,
+                    "rate": 0.666667,
+                    "status": "available",
+                    "states": ["complete", "incomplete"],
+                }
+            ],
+        }
+
+    def details(self, access, **filters):
+        self.calls.append(("details", access, filters))
+        return {
+            "metric": {
+                "code": filters["metric"],
+                "name": "台账完整率",
+                "definition": "固定口径",
+            },
+            "state": filters["state"],
+            "records": [{"asset_uid": "asset-1", "asset_name": "循环泵"}],
+            "page": int(filters["page"]),
+            "page_size": int(filters["page_size"]),
+            "total": 1,
+        }
+
+
+def _client(monkeypatch, *, role="viewer"):
+    from app import create_app
+    from app.api.data_development import routes
+    from app.core.data_research.governance_metrics import GovernanceMetricAccess
+
+    service = FakeGovernanceMetricsService()
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: {
+            "id": "00000000-0000-7000-8000-000000000111",
+            "roles": [role],
+        },
+    )
+    monkeypatch.setattr(
+        routes,
+        "get_governance_metrics_service",
+        lambda: service,
+        raising=False,
+    )
+    monkeypatch.setattr(
+        routes,
+        "_governance_metric_access",
+        lambda: GovernanceMetricAccess(
+            global_access=role == "admin",
+            business_domain_uids=(
+                () if role == "admin" else ("domain-a",)
+            ),
+        ),
+        raising=False,
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), service
+
+
+def test_viewer_reads_summary_with_server_derived_scope(monkeypatch):
+    http, service = _client(monkeypatch)
+
+    response = http.get(
+        "/api/development/v1/governance-metrics/summary"
+    )
+
+    assert response.status_code == 200
+    data = response.get_json()["data"]
+    assert data["scope"] == {
+        "global_access": False,
+        "business_domain_uids": ["domain-a"],
+    }
+    assert data["metrics"][0]["rate"] == 0.666667
+    assert service.calls[0][0] == "summary"
+
+
+def test_viewer_traces_metric_to_bounded_details(monkeypatch):
+    http, service = _client(monkeypatch)
+
+    response = http.get(
+        "/api/development/v1/governance-metrics/details"
+        "?metric=asset_completeness&state=incomplete&page=2&page_size=25"
+    )
+
+    assert response.status_code == 200
+    data = response.get_json()["data"]
+    assert data["records"] == [
+        {"asset_uid": "asset-1", "asset_name": "循环泵"}
+    ]
+    assert service.calls[-1][2] == {
+        "metric": "asset_completeness",
+        "state": "incomplete",
+        "page": "2",
+        "page_size": "25",
+    }
+
+
+def test_invalid_metric_returns_safe_400_contract(monkeypatch):
+    from app.core.data_research.errors import GovernanceMetricInvalid
+
+    http, service = _client(monkeypatch)
+
+    def invalid(*_args, **_kwargs):
+        raise GovernanceMetricInvalid("unsupported metric")
+
+    service.details = invalid
+    response = http.get(
+        "/api/development/v1/governance-metrics/details"
+        "?metric=ranking&state=top"
+    )
+
+    assert response.status_code == 400
+    assert response.get_json()["error"]["code"] == (
+        "GOVERNANCE_METRIC_INVALID"
+    )
+
+
+def test_unexpected_repository_failure_uses_existing_safe_error(monkeypatch):
+    http, service = _client(monkeypatch)
+
+    def unavailable(*_args, **_kwargs):
+        raise RuntimeError("database secret")
+
+    service.summary = unavailable
+    response = http.get(
+        "/api/development/v1/governance-metrics/summary"
+    )
+
+    assert response.status_code == 500
+    payload = response.get_json()
+    assert payload["message"] == "数据采集任务处理失败"
+    assert "database secret" not in repr(payload)
+
+
+def test_admin_scope_is_global_and_anonymous_is_denied(monkeypatch):
+    admin_http, service = _client(monkeypatch, role="admin")
+    admin_response = admin_http.get(
+        "/api/development/v1/governance-metrics/summary"
+    )
+
+    assert admin_response.status_code == 200
+    assert service.calls[0][1].global_access is True
+
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: None,
+    )
+    from app import create_app
+
+    anonymous_response = create_app().test_client().get(
+        "/api/development/v1/governance-metrics/summary"
+    )
+    assert anonymous_response.status_code == 401

+ 63 - 0
tests/data_research/test_governance_metrics_frontend_contract.py

@@ -0,0 +1,63 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_governance_metrics_dashboard_exposes_traceable_read_only_contract():
+    page = (
+        ROOT
+        / "frontend/src/views/dataGovernance/development/governanceMetrics.vue"
+    ).read_text(encoding="utf-8")
+    model = (
+        ROOT
+        / "frontend/src/views/dataGovernance/development/"
+        "governanceMetricsModel.js"
+    ).read_text(encoding="utf-8")
+    api = (ROOT / "frontend/src/api/dataDevelopment.js").read_text(
+        encoding="utf-8"
+    )
+    routes = (ROOT / "frontend/src/router/routes.js").read_text(
+        encoding="utf-8"
+    )
+    center = (
+        ROOT / "frontend/src/views/dataGovernance/development/index.vue"
+    ).read_text(encoding="utf-8")
+
+    for text in (
+        "治理运营指标",
+        "台账完整率",
+        "责任覆盖率",
+        "实体映射率",
+        "问题闭环率",
+        "问题复发率",
+        "实时计算,非手工填报",
+        "按服务端业务域授权过滤",
+        "分子",
+        "分母",
+        "查看明细",
+        "暂无数据",
+    ):
+        assert text in page or text in model
+    for formula_fragment in (
+        "名称、位置、组织、责任人",
+        "已明确责任人",
+        "有效且未回滚实体合并",
+        "已关闭质量问题",
+        "发生次数大于一次",
+    ):
+        assert formula_fragment in page or formula_fragment in model
+    for forbidden in (
+        "item.attributes",
+        "item.evidence",
+        "item.message",
+        "item.config",
+        "item.permission_scope",
+    ):
+        assert forbidden not in page
+    assert "this.$snackbar.error" in page
+    assert "this.$message" not in page
+    assert "getGovernanceMetricSummary" in api
+    assert "getGovernanceMetricDetails" in api
+    assert "/data-governance/development/governance-metrics" in routes
+    assert "governanceMetrics" in routes
+    assert "治理运营指标" in center

+ 424 - 0
tests/integration/test_governance_metrics_postgres.py

@@ -0,0 +1,424 @@
+from __future__ import annotations
+
+import os
+import uuid
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from sqlalchemy import text
+
+pytestmark = pytest.mark.integration
+
+
+def test_governance_metrics_apply_scope_and_return_safe_details(monkeypatch):
+    platform_url = os.environ.get("TEST_DATABASE_URL")
+    if not platform_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+
+    monkeypatch.setenv("DATABASE_URL", platform_url)
+    from app import create_app, db
+    from app.core.data_research.governance_metric_repository import (
+        SqlAlchemyGovernanceMetricRepository,
+    )
+    from app.core.data_research.governance_metrics import GovernanceMetricAccess
+    from app.models.data_research import (
+        DeviceAsset,
+        DeviceAssetSourceMapping,
+        DeviceEntityMatchCandidate,
+        DeviceEntityMatchReview,
+        DeviceEntityMergeEvent,
+        DeviceEntityMergeRollback,
+        DeviceQualityIssue,
+        DeviceQualityProfile,
+        DeviceQualityProfileVersion,
+        DeviceQualityRun,
+        IngestionSource,
+    )
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    suffix = uuid.uuid4().hex[:10]
+    actor_uid = str(uuid.uuid4())
+    now = datetime.now(UTC).replace(microsecond=0)
+    domain_a = str(uuid.uuid4())
+    domain_b = str(uuid.uuid4())
+    source_uids = {
+        "a": str(uuid.uuid4()),
+        "b": str(uuid.uuid4()),
+        "u": str(uuid.uuid4()),
+    }
+    asset_uids = {
+        key: str(uuid.uuid4())
+        for key in ("a1", "a2", "a3", "b", "u", "retired")
+    }
+    mapping_uids = {}
+    candidate_uids = []
+    merge_uids = []
+    issue_uids = []
+    profile_uid = str(uuid.uuid4())
+    profile_version_uid = str(uuid.uuid4())
+    run_uid = str(uuid.uuid4())
+    baseline_admin = None
+
+    try:
+        with app.app_context():
+            baseline_admin = SqlAlchemyGovernanceMetricRepository(
+                db.session
+            ).summary_counts(GovernanceMetricAccess(global_access=True))
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.users (
+                        id, username, display_name, password_hash, status
+                    ) VALUES (
+                        CAST(:id AS uuid), :username, :username,
+                        'integration-test', 'active'
+                    )
+                    """
+                ),
+                {"id": actor_uid, "username": f"wp11-actor-{suffix}"},
+            )
+            for key, scope in (
+                ("a", {"business_domains": [domain_a]}),
+                ("b", {"business_domains": [domain_b]}),
+                ("u", {}),
+            ):
+                db.session.add(
+                    IngestionSource(
+                        uid=source_uids[key],
+                        source_type="database",
+                        name=f"WP11 source {key} {suffix}",
+                        config={"password": f"source-secret-{key}"},
+                        permission_scope=scope,
+                        status="active",
+                        created_by=actor_uid,
+                    )
+                )
+            db.session.flush()
+            for key, _source_key, complete, status in (
+                ("a1", "a", True, "active"),
+                ("a2", "a", False, "active"),
+                ("a3", "a", True, "active"),
+                ("b", "b", True, "active"),
+                ("u", "u", True, "active"),
+                ("retired", "a", True, "retired"),
+            ):
+                db.session.add(
+                    DeviceAsset(
+                        uid=asset_uids[key],
+                        asset_type="device",
+                        name=f"WP11设备-{suffix}-{key}",
+                        status=status,
+                        current_version=1,
+                        content_hash=(key[0] * 64),
+                        location=f"{key}车间",
+                        organization="设备部" if complete else " ",
+                        responsible_person="张工" if complete else None,
+                        attributes={"password": f"asset-secret-{key}"},
+                        created_by=actor_uid,
+                        updated_by=actor_uid,
+                        updated_at=now,
+                    )
+                )
+            db.session.flush()
+            for key, source_key, _complete, _status in (
+                ("a1", "a", True, "active"),
+                ("a2", "a", False, "active"),
+                ("a3", "a", True, "active"),
+                ("b", "b", True, "active"),
+                ("u", "u", True, "active"),
+                ("retired", "a", True, "retired"),
+            ):
+                mapping_uid = str(uuid.uuid4())
+                mapping_uids[key] = mapping_uid
+                db.session.add(
+                    DeviceAssetSourceMapping(
+                        uid=mapping_uid,
+                        asset_uid=asset_uids[key],
+                        source_uid=source_uids[source_key],
+                        source_entity="asset.equipment",
+                        asset_type="device",
+                        source_code=f"EQ-WP11-{suffix}-{key}",
+                        source_updated_at=now,
+                        first_seen_at=now,
+                        last_seen_at=now,
+                    )
+                )
+            db.session.flush()
+
+            def add_merge(left_key, right_key, *, rolled_back=False):
+                candidate_uid = str(uuid.uuid4())
+                review_uid = str(uuid.uuid4())
+                merge_uid = str(uuid.uuid4())
+                candidate_uids.append(candidate_uid)
+                merge_uids.append(merge_uid)
+                db.session.add(
+                    DeviceEntityMatchCandidate(
+                        uid=candidate_uid,
+                        left_asset_uid=asset_uids[left_key],
+                        right_asset_uid=asset_uids[right_key],
+                        canonical_asset_uid=asset_uids[left_key],
+                        status="rolled_back" if rolled_back else "merged",
+                        suggestion_source="manual",
+                        confidence=1,
+                        explanation=[],
+                        evidence_uids=[],
+                        current_version=2 if rolled_back else 1,
+                        created_by=actor_uid,
+                    )
+                )
+                db.session.flush()
+                db.session.add(
+                    DeviceEntityMatchReview(
+                        uid=review_uid,
+                        candidate_uid=candidate_uid,
+                        version=1,
+                        decision="approve",
+                        reason="WP11 integration",
+                        actor_uid=actor_uid,
+                    )
+                )
+                db.session.flush()
+                db.session.add(
+                    DeviceEntityMergeEvent(
+                        uid=merge_uid,
+                        candidate_uid=candidate_uid,
+                        canonical_asset_uid=asset_uids[left_key],
+                        member_asset_uid=asset_uids[right_key],
+                        review_uid=review_uid,
+                        snapshot={"password": "merge-secret"},
+                        actor_uid=actor_uid,
+                    )
+                )
+                db.session.flush()
+                if rolled_back:
+                    db.session.add(
+                        DeviceEntityMergeRollback(
+                            uid=str(uuid.uuid4()),
+                            merge_uid=merge_uid,
+                            candidate_uid=candidate_uid,
+                            reason="WP11 rollback",
+                            snapshot={"password": "rollback-secret"},
+                            actor_uid=actor_uid,
+                        )
+                    )
+
+            add_merge("a1", "a2")
+            add_merge("a3", "b")
+            add_merge("a1", "a3", rolled_back=True)
+
+            db.session.add(
+                DeviceQualityProfile(
+                    uid=profile_uid,
+                    code=f"wp11-{suffix}",
+                    name="WP11 integration",
+                    created_by=actor_uid,
+                )
+            )
+            db.session.flush()
+            db.session.add(
+                DeviceQualityProfileVersion(
+                    uid=profile_version_uid,
+                    profile_uid=profile_uid,
+                    version=1,
+                    status="published",
+                    rules=[],
+                    content_hash=suffix.ljust(64, "0"),
+                    created_by=actor_uid,
+                    published_by=actor_uid,
+                    published_at=now,
+                )
+            )
+            db.session.flush()
+            db.session.add(
+                DeviceQualityRun(
+                    uid=run_uid,
+                    policy_version_uid=profile_version_uid,
+                    policy_hash=suffix.ljust(64, "0"),
+                    source_uid=source_uids["a"],
+                    status="success",
+                    total_assets=3,
+                    total_violations=3,
+                    score=50,
+                    created_by=actor_uid,
+                )
+            )
+            db.session.flush()
+            for index, (asset_key, source_key, status, occurrence) in enumerate(
+                (
+                    ("a1", "a", "closed", 1),
+                    ("a2", "a", "open", 2),
+                    ("b", "b", "closed", 1),
+                ),
+                start=1,
+            ):
+                issue_uid = str(uuid.uuid4())
+                issue_uids.append(issue_uid)
+                db.session.add(
+                    DeviceQualityIssue(
+                        uid=issue_uid,
+                        issue_code=f"W11{suffix[:6]}{index:02d}",
+                        source_violation_uid=str(uuid.uuid4()),
+                        source_run_uid=run_uid,
+                        rule_code="asset_context_complete",
+                        severity="error",
+                        priority="high",
+                        asset_uid=asset_uids[asset_key],
+                        field_name="organization",
+                        source_uid=source_uids[source_key],
+                        source_mapping_uid=mapping_uids[asset_key],
+                        message=f"issue-secret-{asset_key}",
+                        evidence={"password": f"evidence-secret-{asset_key}"},
+                        recurrence_key=(f"{suffix}-{asset_key}").ljust(64, "0"),
+                        occurrence_number=occurrence,
+                        status=status,
+                        assignee_uid=actor_uid,
+                        due_at=now - timedelta(days=1),
+                        current_version=1,
+                        created_by=actor_uid,
+                        updated_by=actor_uid,
+                        created_at=now,
+                        updated_at=now,
+                        closed_at=now if status == "closed" else None,
+                    )
+                )
+            db.session.commit()
+
+            repository = SqlAlchemyGovernanceMetricRepository(db.session)
+            admin = GovernanceMetricAccess(global_access=True)
+            domain_viewer = GovernanceMetricAccess(
+                global_access=False,
+                business_domain_uids=(domain_a,),
+            )
+
+            assert baseline_admin is not None
+            assert repository.summary_counts(admin) == {
+                "asset_completeness": (
+                    baseline_admin["asset_completeness"][0] + 4,
+                    baseline_admin["asset_completeness"][1] + 5,
+                ),
+                "responsibility_coverage": (
+                    baseline_admin["responsibility_coverage"][0] + 4,
+                    baseline_admin["responsibility_coverage"][1] + 5,
+                ),
+                "entity_mapping": (
+                    baseline_admin["entity_mapping"][0] + 4,
+                    baseline_admin["entity_mapping"][1] + 5,
+                ),
+                "issue_closure": (
+                    baseline_admin["issue_closure"][0] + 2,
+                    baseline_admin["issue_closure"][1] + 3,
+                ),
+                "issue_recurrence": (
+                    baseline_admin["issue_recurrence"][0] + 1,
+                    baseline_admin["issue_recurrence"][1] + 3,
+                ),
+            }
+            assert repository.summary_counts(domain_viewer) == {
+                "asset_completeness": (2, 3),
+                "responsibility_coverage": (2, 3),
+                "entity_mapping": (2, 3),
+                "issue_closure": (1, 2),
+                "issue_recurrence": (1, 2),
+            }
+
+            incomplete, incomplete_total = repository.list_details(
+                domain_viewer,
+                metric="asset_completeness",
+                state="incomplete",
+                page=1,
+                page_size=20,
+            )
+            mapped, mapped_total = repository.list_details(
+                domain_viewer,
+                metric="entity_mapping",
+                state="mapped",
+                page=1,
+                page_size=20,
+            )
+            recurrent, recurrent_total = repository.list_details(
+                domain_viewer,
+                metric="issue_recurrence",
+                state="recurrent",
+                page=1,
+                page_size=20,
+            )
+
+            assert incomplete_total == 1
+            assert incomplete[0]["asset_uid"] == asset_uids["a2"]
+            assert incomplete[0]["missing_fields"] == [
+                "organization",
+                "responsible_person",
+            ]
+            assert mapped_total == 2
+            assert {item["asset_uid"] for item in mapped} == {
+                asset_uids["a1"],
+                asset_uids["a2"],
+            }
+            assert recurrent_total == 1
+            assert recurrent[0]["issue_uid"] == issue_uids[1]
+            assert recurrent[0]["overdue"] is True
+            serialized = repr((incomplete, mapped, recurrent))
+            for secret in (
+                "source-secret",
+                "asset-secret",
+                "merge-secret",
+                "issue-secret",
+                "evidence-secret",
+                "permission_scope",
+            ):
+                assert secret not in serialized
+    finally:
+        with app.app_context():
+            for model, column, values in (
+                (DeviceQualityIssue, DeviceQualityIssue.uid, issue_uids),
+                (
+                    DeviceEntityMergeRollback,
+                    DeviceEntityMergeRollback.merge_uid,
+                    merge_uids,
+                ),
+                (
+                    DeviceEntityMergeEvent,
+                    DeviceEntityMergeEvent.uid,
+                    merge_uids,
+                ),
+                (
+                    DeviceEntityMatchReview,
+                    DeviceEntityMatchReview.candidate_uid,
+                    candidate_uids,
+                ),
+                (
+                    DeviceEntityMatchCandidate,
+                    DeviceEntityMatchCandidate.uid,
+                    candidate_uids,
+                ),
+            ):
+                if values:
+                    db.session.query(model).filter(column.in_(values)).delete(
+                        synchronize_session=False
+                    )
+            db.session.query(DeviceQualityRun).filter_by(uid=run_uid).delete()
+            db.session.query(DeviceQualityProfileVersion).filter_by(
+                uid=profile_version_uid
+            ).delete()
+            db.session.query(DeviceQualityProfile).filter_by(
+                uid=profile_uid
+            ).delete()
+            if asset_uids:
+                db.session.query(DeviceAssetSourceMapping).filter(
+                    DeviceAssetSourceMapping.asset_uid.in_(asset_uids.values())
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceAsset).filter(
+                    DeviceAsset.uid.in_(asset_uids.values())
+                ).delete(synchronize_session=False)
+            db.session.query(IngestionSource).filter(
+                IngestionSource.uid.in_(source_uids.values())
+            ).delete(synchronize_session=False)
+            db.session.execute(
+                text(
+                    "DELETE FROM public.users "
+                    "WHERE id = CAST(:id AS uuid)"
+                ),
+                {"id": actor_uid},
+            )
+            db.session.commit()

+ 24 - 0
tests/test_architecture_artifacts.py

@@ -124,6 +124,30 @@ def test_wp10_device_knowledge_contract_and_boundaries_are_documented():
     assert golden_set.exists()
 
 
+def test_wp11_governance_metric_contract_and_boundaries_are_documented():
+    contract = (ARCH / "OPENAPI.yaml").read_text(encoding="utf-8")
+    data_model = (ARCH / "DATA_MODEL.md").read_text(encoding="utf-8")
+
+    for path in (
+        "/api/development/v1/governance-metrics/summary",
+        "/api/development/v1/governance-metrics/details",
+    ):
+        assert path in contract
+    for field in ("scope", "metrics", "metric", "state", "records", "total"):
+        assert field in contract
+    for term in (
+        "台账完整率",
+        "责任覆盖率",
+        "实体映射率",
+        "问题闭环率",
+        "问题复发率",
+        "rate = null",
+        "authorized_sources",
+        "不依赖前端过滤",
+    ):
+        assert term in data_model
+
+
 def test_contract_ci_regenerates_and_checks_the_committed_inventory():
     workflow = (ROOT / ".github" / "workflows" / "contracts.yml").read_text(
         encoding="utf-8"

+ 8 - 0
tests/test_permission_matrix.py

@@ -142,6 +142,14 @@ def test_data_development_paths_have_specific_write_policies():
         "/api/development/v1/device-observability/import",
         "POST",
     ) == ("device-observability:edit",)
+    assert permission_for_request(
+        "/api/development/v1/governance-metrics/summary",
+        "GET",
+    ) == ("governance:read",)
+    assert permission_for_request(
+        "/api/development/v1/governance-metrics/details",
+        "GET",
+    ) == ("governance:read",)
 
 
 def test_business_domain_read_endpoints_are_available_to_viewers():