Explorar o código

feat: add device governance responsibility matrix

马小龙 hai 3 semanas
pai
achega
262729acd7

+ 1 - 0
app/api/system/__init__.py

@@ -3,5 +3,6 @@ from flask import Blueprint
 bp = Blueprint("system", __name__)
 
 from app.api.system import routes  # noqa: E402, F401
+from app.api.system import responsibilities  # noqa: E402, F401
 from app.api.system import users  # noqa: E402, F401
 from app.api.system import workbench  # noqa: E402, F401

+ 81 - 0
app/api/system/responsibilities.py

@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.core.governance.responsibilities import (
+    ResponsibilityConflict,
+    ResponsibilityService,
+    ResponsibilityUserUnavailable,
+    ResponsibilityValidationError,
+    SqlAlchemyResponsibilityRepository,
+)
+from app.core.system.permissions import (
+    RESPONSIBILITIES_MANAGE,
+    RESPONSIBILITIES_READ,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+
+def _service() -> ResponsibilityService:
+    return ResponsibilityService(SqlAlchemyResponsibilityRepository(db.session))
+
+
+def _etag(response, revision: int):
+    response.headers["ETag"] = f'"{int(revision)}"'
+    return response
+
+
+def _expected_revision() -> int:
+    raw = str(request.headers.get("If-Match") or "").strip()
+    if raw.startswith("W/"):
+        raw = raw[2:].strip()
+    raw = raw.strip('"')
+    if not raw or not raw.isdigit():
+        raise ResponsibilityValidationError("missing valid If-Match revision")
+    return int(raw)
+
+
+@bp.route(
+    "/responsibilities/<resource_type>/<resource_uid>",
+    methods=["GET"],
+)
+@require_permissions(RESPONSIBILITIES_READ)
+def get_responsibility_matrix(resource_type: str, resource_uid: str):
+    try:
+        result = _service().get(resource_type, resource_uid)
+        return _etag(jsonify(success(result)), result["revision"])
+    except ResponsibilityValidationError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.route(
+    "/responsibilities/<resource_type>/<resource_uid>",
+    methods=["PUT"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def replace_responsibility_matrix(resource_type: str, resource_uid: str):
+    try:
+        revision = _expected_revision()
+        body = request.get_json(silent=True) or {}
+        result = _service().replace(
+            resource_type=resource_type,
+            resource_uid=resource_uid,
+            assignments=body.get("assignments"),
+            expected_revision=revision,
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return _etag(jsonify(success(result, "责任矩阵已更新")), result["revision"])
+    except ResponsibilityConflict as exc:
+        db.session.rollback()
+        return jsonify(failed(str(exc), code=409)), 409
+    except ResponsibilityUserUnavailable as exc:
+        db.session.rollback()
+        return jsonify(failed(str(exc), code=422)), 422
+    except ResponsibilityValidationError as exc:
+        db.session.rollback()
+        status = 428 if "If-Match" in str(exc) else 400
+        return jsonify(failed(str(exc), code=status)), status

+ 17 - 0
app/core/governance/__init__.py

@@ -0,0 +1,17 @@
+"""Governance responsibility and operating-model services."""
+
+from app.core.governance.responsibilities import (
+    ResponsibilityConflict,
+    ResponsibilityService,
+    ResponsibilityUserUnavailable,
+    ResponsibilityValidationError,
+    SqlAlchemyResponsibilityRepository,
+)
+
+__all__ = [
+    "ResponsibilityConflict",
+    "ResponsibilityService",
+    "ResponsibilityUserUnavailable",
+    "ResponsibilityValidationError",
+    "SqlAlchemyResponsibilityRepository",
+]

+ 386 - 0
app/core/governance/responsibilities.py

@@ -0,0 +1,386 @@
+"""Versioned RACI bindings for governed resources and device accountability."""
+
+from __future__ import annotations
+
+import json
+import uuid
+from dataclasses import asdict, dataclass
+from typing import Any
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+
+
+RESOURCE_TYPES = frozenset(
+    {
+        "business_domain",
+        "device_asset",
+        "device_ontology",
+        "device_mapping",
+        "fault_classification",
+        "quality_issue",
+    }
+)
+DEVICE_RESOURCE_TYPES = frozenset(
+    {
+        "device_asset",
+        "device_ontology",
+        "device_mapping",
+        "fault_classification",
+    }
+)
+RESPONSIBILITY_ROLES = frozenset(
+    {"domain_owner", "data_steward", "data_architect", "asset_manager"}
+)
+RACI_ROLES = frozenset({"responsible", "accountable", "consulted", "informed"})
+
+
+class ResponsibilityError(RuntimeError):
+    """Base error for responsibility-matrix operations."""
+
+
+class ResponsibilityValidationError(ResponsibilityError):
+    """The requested matrix violates its governed contract."""
+
+
+class ResponsibilityConflict(ResponsibilityError):
+    """The matrix changed after the caller loaded it."""
+
+
+class ResponsibilityUserUnavailable(ResponsibilityError):
+    """An assignment targets an unknown or disabled user."""
+
+
+@dataclass(frozen=True)
+class ResponsibilityAssignment:
+    user_id: str
+    responsibility_role: str
+    raci_role: str
+
+
+def _valid_uuid(value: Any, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (ValueError, TypeError, AttributeError) as exc:
+        raise ResponsibilityValidationError(f"{field} must be a UUID") from exc
+
+
+def validate_resource(resource_type: str, resource_uid: str | None = None) -> None:
+    if resource_type not in RESOURCE_TYPES:
+        raise ResponsibilityValidationError("unsupported resource type")
+    if resource_uid is None:
+        return
+    value = str(resource_uid).strip()
+    if not value or len(value) > 120:
+        raise ResponsibilityValidationError("resource uid is invalid")
+
+
+def validate_matrix(
+    resource_type: str,
+    assignments: list[dict[str, Any]],
+) -> tuple[ResponsibilityAssignment, ...]:
+    validate_resource(resource_type)
+    if not isinstance(assignments, list) or not assignments:
+        raise ResponsibilityValidationError("responsibility matrix cannot be empty")
+
+    validated: list[ResponsibilityAssignment] = []
+    identities: set[tuple[str, str, str]] = set()
+    for raw in assignments:
+        if not isinstance(raw, dict):
+            raise ResponsibilityValidationError("assignment must be an object")
+        user_id = _valid_uuid(raw.get("user_id"), "user id")
+        responsibility_role = str(raw.get("responsibility_role") or "").strip()
+        raci_role = str(raw.get("raci_role") or "").strip()
+        if responsibility_role not in RESPONSIBILITY_ROLES:
+            raise ResponsibilityValidationError("unsupported responsibility role")
+        if raci_role not in RACI_ROLES:
+            raise ResponsibilityValidationError("unsupported RACI role")
+        identity = (user_id, responsibility_role, raci_role)
+        if identity in identities:
+            raise ResponsibilityValidationError("duplicate responsibility assignment")
+        identities.add(identity)
+        validated.append(
+            ResponsibilityAssignment(
+                user_id=user_id,
+                responsibility_role=responsibility_role,
+                raci_role=raci_role,
+            )
+        )
+
+    if resource_type in DEVICE_RESOURCE_TYPES:
+        accountable_asset_managers = [
+            item
+            for item in validated
+            if item.responsibility_role == "asset_manager"
+            and item.raci_role == "accountable"
+        ]
+        if len(accountable_asset_managers) != 1:
+            raise ResponsibilityValidationError(
+                "device scope requires exactly one accountable asset manager"
+            )
+    return tuple(validated)
+
+
+class ResponsibilityService:
+    def __init__(self, repository):
+        self.repository = repository
+
+    def get(self, resource_type: str, resource_uid: str) -> dict[str, Any]:
+        validate_resource(resource_type, resource_uid)
+        return self.repository.get(resource_type, resource_uid)
+
+    def replace(
+        self,
+        *,
+        resource_type: str,
+        resource_uid: str,
+        assignments: list[dict[str, Any]],
+        expected_revision: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        validate_resource(resource_type, resource_uid)
+        actor_uid = _valid_uuid(actor_uid, "actor uid")
+        try:
+            revision = int(expected_revision)
+        except (TypeError, ValueError) as exc:
+            raise ResponsibilityValidationError("revision is invalid") from exc
+        if revision < 0:
+            raise ResponsibilityValidationError("revision is invalid")
+        return self.repository.replace(
+            resource_type=resource_type,
+            resource_uid=str(resource_uid).strip(),
+            assignments=validate_matrix(resource_type, assignments),
+            expected_revision=revision,
+            actor_uid=actor_uid,
+        )
+
+
+class SqlAlchemyResponsibilityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _assignment_dict(row) -> dict[str, Any]:
+        return {
+            "user_id": str(row["user_id"]),
+            "username": row["username"],
+            "display_name": row["display_name"],
+            "responsibility_role": row["responsibility_role"],
+            "raci_role": row["raci_role"],
+        }
+
+    def _assignments(self, scope_id: str) -> list[dict[str, Any]]:
+        rows = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT a.user_id::text AS user_id, u.username,
+                           u.display_name, a.responsibility_role, a.raci_role
+                    FROM public.governance_responsibility_assignments a
+                    JOIN public.users u ON u.id = a.user_id
+                    WHERE a.scope_id = CAST(:scope_id AS uuid)
+                    ORDER BY a.raci_role, a.responsibility_role, u.username
+                    """
+                ),
+                {"scope_id": scope_id},
+            )
+            .mappings()
+            .all()
+        )
+        return [self._assignment_dict(row) for row in rows]
+
+    def get(self, resource_type: str, resource_uid: str) -> dict[str, Any]:
+        scope = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT id::text AS id, revision
+                    FROM public.governance_responsibility_scopes
+                    WHERE resource_type = :resource_type
+                      AND resource_uid = :resource_uid
+                    """
+                ),
+                {
+                    "resource_type": resource_type,
+                    "resource_uid": resource_uid,
+                },
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if scope is None:
+            return {
+                "resource_type": resource_type,
+                "resource_uid": resource_uid,
+                "revision": 0,
+                "assignments": [],
+            }
+        return {
+            "resource_type": resource_type,
+            "resource_uid": resource_uid,
+            "revision": int(scope["revision"]),
+            "assignments": self._assignments(scope["id"]),
+        }
+
+    def replace(
+        self,
+        *,
+        resource_type: str,
+        resource_uid: str,
+        assignments: tuple[ResponsibilityAssignment, ...],
+        expected_revision: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        lock_key = f"responsibility:{resource_type}:{resource_uid}"
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+            {"key": lock_key},
+        )
+        scope = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT id::text AS id, revision
+                    FROM public.governance_responsibility_scopes
+                    WHERE resource_type = :resource_type
+                      AND resource_uid = :resource_uid
+                    FOR UPDATE
+                    """
+                ),
+                {
+                    "resource_type": resource_type,
+                    "resource_uid": resource_uid,
+                },
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if scope is None:
+            if expected_revision != 0:
+                raise ResponsibilityConflict("responsibility revision conflict")
+            scope_id = new_governance_uid()
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.governance_responsibility_scopes (
+                        id, resource_type, resource_uid, revision,
+                        updated_by
+                    ) VALUES (
+                        CAST(:id AS uuid), :resource_type, :resource_uid, 0,
+                        CAST(:actor_uid AS uuid)
+                    )
+                    """
+                ),
+                {
+                    "id": scope_id,
+                    "resource_type": resource_type,
+                    "resource_uid": resource_uid,
+                    "actor_uid": actor_uid,
+                },
+            )
+            current_revision = 0
+            before: list[dict[str, Any]] = []
+        else:
+            scope_id = str(scope["id"])
+            current_revision = int(scope["revision"])
+            if current_revision != expected_revision:
+                raise ResponsibilityConflict("responsibility revision conflict")
+            before = self._assignments(scope_id)
+
+        user_ids = sorted({item.user_id for item in assignments})
+        active_user_ids = {
+            str(row[0])
+            for row in self.session.execute(
+                text(
+                    """
+                    SELECT id::text FROM public.users
+                    WHERE status = 'active' AND id::text = ANY(:user_ids)
+                    """
+                ),
+                {"user_ids": user_ids},
+            )
+        }
+        if active_user_ids != set(user_ids):
+            raise ResponsibilityUserUnavailable(
+                "responsibility user is unknown or disabled"
+            )
+
+        self.session.execute(
+            text(
+                """
+                DELETE FROM public.governance_responsibility_assignments
+                WHERE scope_id = CAST(:scope_id AS uuid)
+                """
+            ),
+            {"scope_id": scope_id},
+        )
+        for assignment in assignments:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.governance_responsibility_assignments (
+                        id, scope_id, user_id, responsibility_role,
+                        raci_role, assigned_by
+                    ) VALUES (
+                        CAST(:id AS uuid), CAST(:scope_id AS uuid),
+                        CAST(:user_id AS uuid), :responsibility_role,
+                        :raci_role, CAST(:actor_uid AS uuid)
+                    )
+                    """
+                ),
+                {
+                    "id": new_governance_uid(),
+                    "scope_id": scope_id,
+                    "user_id": assignment.user_id,
+                    "responsibility_role": assignment.responsibility_role,
+                    "raci_role": assignment.raci_role,
+                    "actor_uid": actor_uid,
+                },
+            )
+
+        new_revision = current_revision + 1
+        self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_scopes
+                SET revision = :revision, updated_by = CAST(:actor_uid AS uuid),
+                    updated_at = CURRENT_TIMESTAMP
+                WHERE id = CAST(:scope_id AS uuid)
+                """
+            ),
+            {
+                "scope_id": scope_id,
+                "revision": new_revision,
+                "actor_uid": actor_uid,
+            },
+        )
+        requested = [asdict(item) for item in assignments]
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_audit_events (
+                    scope_id, resource_type, resource_uid, actor_uid,
+                    action, before_state, after_state
+                ) VALUES (
+                    CAST(:scope_id AS uuid), :resource_type, :resource_uid,
+                    CAST(:actor_uid AS uuid), 'matrix_replaced',
+                    CAST(:before_state AS jsonb), CAST(:after_state AS jsonb)
+                )
+                """
+            ),
+            {
+                "scope_id": scope_id,
+                "resource_type": resource_type,
+                "resource_uid": resource_uid,
+                "actor_uid": actor_uid,
+                "before_state": json.dumps(before, ensure_ascii=False),
+                "after_state": json.dumps(requested, ensure_ascii=False),
+            },
+        )
+        return {
+            "resource_type": resource_type,
+            "resource_uid": resource_uid,
+            "revision": new_revision,
+            "assignments": self._assignments(scope_id),
+        }

+ 12 - 1
app/core/system/permissions.py

@@ -14,6 +14,8 @@ MANAGE_USERS = "users:manage"
 ACTIVATE_WORKFLOW = "workflow:activate"
 OPERATE_ORDERS = "orders:operate"
 DATASOURCE_POOL_MANAGE = "datasources:pools:manage"
+RESPONSIBILITIES_READ = "governance:responsibilities:read"
+RESPONSIBILITIES_MANAGE = "governance:responsibilities:manage"
 KNOWLEDGE_MANAGE = "knowledge:manage"
 RULES_READ = "rules:read"
 RULES_EDIT = "rules:edit"
@@ -33,7 +35,9 @@ ONTOLOGIES_EDIT = "ontologies:edit"
 ONTOLOGIES_PUBLISH = "ontologies:publish"
 
 ROLE_PERMISSIONS = {
-    "viewer": frozenset({READ_GOVERNANCE, RULES_READ}),
+    "viewer": frozenset(
+        {READ_GOVERNANCE, RULES_READ, RESPONSIBILITIES_READ}
+    ),
     "editor": frozenset(
         {
             READ_GOVERNANCE,
@@ -43,6 +47,7 @@ ROLE_PERMISSIONS = {
             RULES_READ,
             RULES_EDIT,
             RULES_EXECUTE,
+            RESPONSIBILITIES_READ,
             INGESTION_RUN,
             DATA_ELEMENTS_EDIT,
             ONTOLOGIES_EDIT,
@@ -57,6 +62,8 @@ ROLE_PERMISSIONS = {
             ACTIVATE_WORKFLOW,
             OPERATE_ORDERS,
             DATASOURCE_POOL_MANAGE,
+            RESPONSIBILITIES_READ,
+            RESPONSIBILITIES_MANAGE,
             KNOWLEDGE_MANAGE,
             RULES_READ,
             RULES_EDIT,
@@ -86,6 +93,10 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
     method = method.upper()
     if path in {"/api/system/health", "/api/system/auth/login"}:
         return (PUBLIC,)
+    if path.startswith("/api/system/responsibilities/"):
+        if method == "GET":
+            return (RESPONSIBILITIES_READ,)
+        return (RESPONSIBILITIES_MANAGE,)
     if path in {"/api/knowledge/search", "/api/knowledge/ask"}:
         return (READ_GOVERNANCE,)
     if path.startswith("/api/rules"):

+ 9 - 0
docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md

@@ -148,6 +148,15 @@ P2 不阻塞第一阶段验收。没有完成的 P2 功能必须保留接口和
 | WP-13 | 部署、备份与恢复 | P0 | 测试交付、技术负责人 | Compose 离线安装、升级检查、数据库迁移、备份、恢复和回滚 | 安装包、部署手册、备份文件、恢复演练报告 |
 | WP-14 | 用户验收与移交 | P0 | 产品负责人、验收负责人 | 真实场景 UAT、缺陷收口、培训、运维交接和第二阶段待办 | UAT 报告、缺陷清单、操作手册、移交清单 |
 
+### 6.1 实施状态台账
+
+| 工作包 | 当前状态 | 已形成证据 | 未完成边界 |
+|---|---|---|---|
+| WP-00 | 进行中 | 基准提交 `0d6f746`;第一阶段分支 `codex/dataops-phase1-equipment-governance` | 企业负责人、真实数据范围和验收责任人仍需项目现场确认 |
+| WP-01 | 工程完成 | 合并提交 `cab9c1f`;后端 839 项回归、前端构建、OpenAPI 142 项和 Docker 本体链路通过 | `deployment/app` 历史发布副本仍有基线差异,正式交付前按 WP-13 统一收口 |
+| WP-02 | 工程完成,待企业配置 | 本地三级 RBAC;设备责任矩阵;唯一最终设备资产管理员;修订、并发冲突和审计快照;OpenAPI 144 项 | 需要企业提供实际设备资产管理员、治理人员和查看者名单后配置并完成 UAT |
+| WP-03 | 待启动 | 已具备 PostgreSQL/MySQL 连接池、多源采集和证据链底座 | 需要企业设备台账库、维修库只读账号、网络和数据字典 |
+
 ## 7. 12 周执行计划
 
 ### 7.1 第一个月:收口底座,打通接入与建账

+ 5 - 5
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -470,8 +470,8 @@ DataOps Platform 当前已经具备较完整的“治理对象 → 知识服务
 |---|---|---|---|
 | GOV-01 | 治理组织 / 业务域 | 业务域列表、详情、创建、更新、删除和图谱 | 已建设 |
 | GOV-02 | 治理组织 / 联邦治理 | 中央团队制定政策、业务域自治运营的联邦治理模型 | 部分建设 |
-| GOV-03 | 治理组织 / 责任角色 | 业务域 Owner、Data Steward、数据架构师和资产管理员 | 规划中 |
-| GOV-04 | 治理组织 / 责任矩阵 | 资产、标准、本体、质量和数据产品的 RACI | 规划中 |
+| GOV-03 | 治理组织 / 责任角色 | 业务域 Owner、Data Steward、数据架构师和资产管理员 | 后端与管理界面完成,待企业配置 |
+| GOV-04 | 治理组织 / 责任矩阵 | 资产、标准、本体、质量和数据产品的 RACI | 设备域完成,其他对象待扩展 |
 | GOV-05 | 治理组织 / 责任继承 | 组织、业务域、资产层级的责任人继承与覆盖 | 规划中 |
 | GOV-06 | 治理组织 / 委派代理 | 责任人代理、休假委托、离岗转交和超时升级 | 规划中 |
 | GOV-07 | 治理组织 / 跨域协同 | 跨业务域资产、标准、本体和权限变更联合评审 | 规划中 |
@@ -481,7 +481,7 @@ DataOps Platform 当前已经具备较完整的“治理对象 → 知识服务
 | GOV-11 | 治理运营 / 责任考核 | 质量问题闭环率、逾期率、复发率和责任人绩效 | 规划中 |
 | GOV-12 | 治理运营 / 排名看板 | 业务域治理看板、趋势和内部对标 | 规划中 |
 | GOV-13 | 治理运营 / 成熟度评估 | 治理成熟度模型、制度执行检查和管理层驾驶舱 | 规划中 |
-| GOV-14 | 治理运营 / 设备责任 | 设备资产管理员作为设备本体、映射和故障分类审批人 | 规划中 |
+| GOV-14 | 治理运营 / 设备责任 | 设备资产管理员作为设备本体、映射和故障分类审批人 | 已建设 |
 
 ### 12.3 数据连接与企业侧执行
 
@@ -774,8 +774,8 @@ DataOps Platform 当前已经具备较完整的“治理对象 → 知识服务
 
 | 序号 | 关联模块 | 三个月示范版功能 | 交付边界 |
 |---:|---|---|---|
-| 1 | IAM-06、IAM-08 | 企业 OIDC 单点登录 | 对接一个企业 IdP,完成 Claims 到平台角色的基础映射 |
-| 2 | IAM-04、GOV-03 | 设备域角色与责任人 | 复用现有 RBAC,增加设备资产管理员责任绑定 |
+| 1 | IAM-01~05、IAM-11 | 本地登录与角色权限 | 首期复用本地登录和三级 RBAC;企业 OIDC SSO 顺延至后续阶段 |
+| 2 | IAM-04、GOV-03、GOV-04、GOV-14 | 设备域角色与责任人 | 配置设备资产管理员、治理人员和查看者,形成带修订与审计的 RACI 责任矩阵 |
 | 3 | CON-01~05 | 关系数据库接入 | 复用 PostgreSQL/MySQL 安全连接池接入设备台账和维修库 |
 | 4 | CON-09 | MES/SCADA/IoT 元数据接入 | 首期通过其数据库或既有接口采集资产、测点和 Schema,不开发全量工业协议 |
 | 5 | CON-17~19 | 企业内数据边界 | 全部部署在企业内网;保存聚合运行信息和近期明细,不向外传原始数据 |

+ 3 - 0
docs/architecture/DATA_MODEL.md

@@ -126,6 +126,9 @@ flowchart LR
 | `users` | `id UUID`, `username`, `password_hash`, `status` | 替换旧明文/可逆密码字段 |
 | `roles` | `id`, `code` | 固定 `admin/editor/viewer`,即管理员、编辑者、查看者 |
 | `user_roles` | `user_id`, `role_id` | 用户与角色映射 |
+| `governance_responsibility_scopes` | `resource_type`, `resource_uid`, `revision`, `updated_by` | 业务域、设备、本体、映射、故障分类和质量问题的责任矩阵版本 |
+| `governance_responsibility_assignments` | `scope_id`, `user_id`, `responsibility_role`, `raci_role` | Owner、Steward、架构师、设备资产管理员与 RACI 责任绑定 |
+| `governance_responsibility_audit_events` | `resource_type`, `resource_uid`, `actor_uid`, `before_state`, `after_state` | 责任矩阵变更前后快照与操作审计 |
 | `dataflow_workflow_versions` | `id`, `dataflow_uid`, `environment`, `version_no`, `n8n_workflow_id`, `status` | 一个 DataFlow 对多个 n8n Workflow 版本 |
 | `governance_documents` | `object_type`, `object_uid`, `object_version`, `content_hash` | 治理对象文本快照 |
 | `governance_chunks` | `document_id`, `chunk_no`, `content`, `embedding vector` | Qwen Embedding 结果 |

+ 67 - 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: 142
+x-route-count: 144
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -3496,6 +3496,72 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/responsibilities/{resource_type}/{resource_uid}":
+    get:
+      tags: [system]
+      operationId: system_get_responsibility_matrix_get
+      summary: "get responsibility matrix"
+      x-source: "app/api/system/routes.py"
+      parameters:
+        - name: resource_type
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: resource_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    put:
+      tags: [system]
+      operationId: system_replace_responsibility_matrix_put
+      summary: "replace responsibility matrix"
+      x-source: "app/api/system/routes.py"
+      parameters:
+        - name: resource_type
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: resource_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/system/translate":
     post:
       tags: [system]

+ 17 - 0
frontend/src/api/responsibilities.js

@@ -0,0 +1,17 @@
+import http from '@/utils/request'
+
+function resourcePath (resourceType, resourceUid) {
+  return `/system/responsibilities/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceUid)}`
+}
+
+export function getResponsibilityMatrix (resourceType, resourceUid) {
+  return http.get(resourcePath(resourceType, resourceUid))
+}
+
+export function replaceResponsibilityMatrix (resourceType, resourceUid, assignments, revision) {
+  return http.put(
+    resourcePath(resourceType, resourceUid),
+    { assignments },
+    { headers: { 'If-Match': `"${revision}"` } }
+  )
+}

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

@@ -1643,6 +1643,23 @@ export default {
           },
           name: 'systemUserManage',
           alwaysShow: 0
+        },
+        {
+          hidden: 0,
+          type: 1,
+          title: '设备责任矩阵',
+          path: '/systemManage/responsibilities',
+          children: [],
+          label: '设备责任矩阵',
+          sort: 2,
+          component: 'systemManage/responsibility',
+          meta: {
+            title: '设备责任矩阵',
+            icon: 'mdi-shield-account-outline',
+            permissions: ['governance:responsibilities:manage']
+          },
+          name: 'systemResponsibilityManage',
+          alwaysShow: 0
         }
       ],
       label: '系统管理',

+ 7 - 1
frontend/src/utils/request.js

@@ -110,9 +110,15 @@ const http = {
       }
     })
   },
-  put (url, params) {
+  put (url, params, config = {}) {
     return service.put(url, {
       ...params
+    }, {
+      ...config,
+      headers: {
+        'Content-Type': 'application/json',
+        ...(config.headers || {})
+      }
     })
   },
   del (url, params) {

+ 314 - 0
frontend/src/views/systemManage/responsibility/index.vue

@@ -0,0 +1,314 @@
+<template>
+  <v-container fluid class="responsibility-page">
+    <div class="d-flex align-start mb-5">
+      <div>
+        <div class="overline primary--text">GOVERNANCE ACCOUNTABILITY</div>
+        <h2 class="mb-1">设备责任矩阵</h2>
+        <div class="text--secondary">
+          为设备台账、本体、跨系统映射和故障分类指定唯一最终审批人
+        </div>
+      </div>
+      <v-spacer />
+      <v-chip v-if="loaded" outlined color="primary">
+        修订 {{ revision }}
+      </v-chip>
+    </div>
+
+    <v-alert text color="blue-grey" icon="mdi-shield-account-outline">
+      平台角色决定功能权限;责任矩阵决定用户对具体业务对象承担的职责。两者相互独立,
+      设备资产管理员必须同时拥有所需的平台操作权限。
+    </v-alert>
+
+    <v-card outlined class="mb-4">
+      <v-card-text>
+        <v-row align="center">
+          <v-col cols="12" md="4">
+            <v-select
+              v-model="resourceType"
+              :items="resourceTypeOptions"
+              item-text="text"
+              item-value="value"
+              label="治理对象类型"
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" md="5">
+            <v-text-field
+              v-model.trim="resourceUid"
+              label="对象标识"
+              placeholder="例如:DEVICE-DOMAIN 或设备 UID"
+              hide-details
+              @keyup.enter="loadMatrix"
+            />
+          </v-col>
+          <v-col cols="12" md="3" class="d-flex justify-end">
+            <v-btn
+              color="primary"
+              outlined
+              :loading="loading"
+              :disabled="!resourceUid"
+              @click="loadMatrix"
+            >
+              <v-icon left>mdi-database-search-outline</v-icon>
+              加载责任矩阵
+            </v-btn>
+          </v-col>
+        </v-row>
+      </v-card-text>
+    </v-card>
+
+    <v-card v-if="loaded" outlined>
+      <v-card-title class="d-flex align-center">
+        <div>
+          <div class="subtitle-1 font-weight-medium">{{ selectedResourceLabel }}</div>
+          <div class="caption text--secondary">{{ resourceUid }}</div>
+        </div>
+        <v-spacer />
+        <v-btn text color="primary" @click="addAssignment">
+          <v-icon left>mdi-account-plus-outline</v-icon>
+          添加责任人
+        </v-btn>
+      </v-card-title>
+      <v-divider />
+      <v-card-text>
+        <v-alert
+          v-if="deviceScope"
+          dense
+          text
+          type="info"
+          class="mb-4"
+        >
+          设备治理对象必须且只能设置一名“设备资产管理员 + Accountable”,
+          其他人员可按 RACI 参与执行、咨询或知会。
+        </v-alert>
+
+        <v-row
+          v-for="(assignment, index) in assignments"
+          :key="`assignment-${index}`"
+          align="center"
+          class="assignment-row"
+        >
+          <v-col cols="12" md="4">
+            <v-select
+              v-model="assignment.user_id"
+              :items="activeUsers"
+              :item-text="userLabel"
+              item-value="id"
+              label="用户"
+              dense
+              outlined
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" md="3">
+            <v-select
+              v-model="assignment.responsibility_role"
+              :items="responsibilityRoleOptions"
+              item-text="text"
+              item-value="value"
+              label="责任类型"
+              dense
+              outlined
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" md="3">
+            <v-select
+              v-model="assignment.raci_role"
+              :items="raciOptions"
+              item-text="text"
+              item-value="value"
+              label="RACI"
+              dense
+              outlined
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" md="2" class="text-right">
+            <v-btn icon color="grey" @click="removeAssignment(index)">
+              <v-icon>mdi-delete-outline</v-icon>
+            </v-btn>
+          </v-col>
+        </v-row>
+
+        <div v-if="!assignments.length" class="empty-state text-center py-8">
+          <v-icon size="42" color="blue-grey lighten-2">mdi-account-question-outline</v-icon>
+          <div class="mt-2 text--secondary">尚未配置责任人</div>
+        </div>
+      </v-card-text>
+      <v-divider />
+      <v-card-actions class="px-6 py-4">
+        <div class="caption text--secondary">
+          保存时会记录操作者、修订号以及变更前后快照
+        </div>
+        <v-spacer />
+        <v-btn text @click="loadMatrix">放弃更改</v-btn>
+        <v-btn color="primary" :loading="saving" @click="saveMatrix">
+          保存责任矩阵
+        </v-btn>
+      </v-card-actions>
+    </v-card>
+  </v-container>
+</template>
+
+<script>
+import { listUsers } from '@/api/users'
+import {
+  getResponsibilityMatrix,
+  replaceResponsibilityMatrix
+} from '@/api/responsibilities'
+
+const DEVICE_SCOPES = [
+  'device_asset',
+  'device_ontology',
+  'device_mapping',
+  'fault_classification'
+]
+
+export default {
+  name: 'SystemResponsibilityManage',
+  data () {
+    return {
+      loading: false,
+      saving: false,
+      loaded: false,
+      revision: 0,
+      resourceType: 'device_asset',
+      resourceUid: '',
+      users: [],
+      assignments: [],
+      resourceTypeOptions: [
+        { text: '设备资产', value: 'device_asset' },
+        { text: '设备本体', value: 'device_ontology' },
+        { text: '设备跨系统映射', value: 'device_mapping' },
+        { text: '故障分类', value: 'fault_classification' },
+        { text: '业务域', value: 'business_domain' },
+        { text: '质量问题', value: 'quality_issue' }
+      ],
+      responsibilityRoleOptions: [
+        { text: '业务域 Owner', value: 'domain_owner' },
+        { text: 'Data Steward', value: 'data_steward' },
+        { text: '数据架构师', value: 'data_architect' },
+        { text: '设备资产管理员', value: 'asset_manager' }
+      ],
+      raciOptions: [
+        { text: 'Responsible — 执行负责', value: 'responsible' },
+        { text: 'Accountable — 最终负责', value: 'accountable' },
+        { text: 'Consulted — 协商参与', value: 'consulted' },
+        { text: 'Informed — 结果知会', value: 'informed' }
+      ]
+    }
+  },
+  computed: {
+    activeUsers () {
+      return this.users.filter(item => item.status === 'active')
+    },
+    deviceScope () {
+      return DEVICE_SCOPES.includes(this.resourceType)
+    },
+    selectedResourceLabel () {
+      return this.resourceTypeOptions.find(item => item.value === this.resourceType)?.text || this.resourceType
+    }
+  },
+  async created () {
+    try {
+      const response = await listUsers()
+      this.users = response.data || []
+    } catch (error) {
+      this.$snackbar.error(error)
+    }
+  },
+  methods: {
+    userLabel (item) {
+      return `${item.display_name || item.username}(${item.username})`
+    },
+    addAssignment () {
+      this.assignments.push({
+        user_id: null,
+        responsibility_role: this.deviceScope ? 'asset_manager' : 'data_steward',
+        raci_role: this.assignments.length ? 'responsible' : 'accountable'
+      })
+    },
+    removeAssignment (index) {
+      this.assignments.splice(index, 1)
+    },
+    async loadMatrix () {
+      if (!this.resourceUid) return
+      this.loading = true
+      try {
+        const response = await getResponsibilityMatrix(this.resourceType, this.resourceUid)
+        this.revision = response.data.revision
+        this.assignments = response.data.assignments.map(item => ({
+          user_id: item.user_id,
+          responsibility_role: item.responsibility_role,
+          raci_role: item.raci_role
+        }))
+        this.loaded = true
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loading = false
+      }
+    },
+    validate () {
+      if (!this.assignments.length || this.assignments.some(item => !item.user_id)) {
+        return '请完整配置至少一名责任人'
+      }
+      if (this.deviceScope) {
+        const accountable = this.assignments.filter(item => (
+          item.responsibility_role === 'asset_manager' &&
+          item.raci_role === 'accountable'
+        ))
+        if (accountable.length !== 1) {
+          return '设备治理对象必须且只能设置一名最终负责的设备资产管理员'
+        }
+      }
+      return null
+    },
+    async saveMatrix () {
+      const error = this.validate()
+      if (error) {
+        this.$snackbar.error(error)
+        return
+      }
+      this.saving = true
+      try {
+        const response = await replaceResponsibilityMatrix(
+          this.resourceType,
+          this.resourceUid,
+          this.assignments,
+          this.revision
+        )
+        this.revision = response.data.revision
+        this.$snackbar.success('责任矩阵已保存')
+        await this.loadMatrix()
+      } catch (error) {
+        if (error?.code === 409) {
+          this.$snackbar.error('责任矩阵已被其他管理员修改,请重新加载')
+        } else {
+          this.$snackbar.error(error)
+        }
+      } finally {
+        this.saving = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.responsibility-page {
+  max-width: 1280px;
+}
+
+.assignment-row + .assignment-row {
+  border-top: 1px solid rgba(15, 23, 42, 0.08);
+  padding-top: 16px;
+}
+
+.empty-state {
+  border: 1px dashed rgba(55, 71, 79, 0.28);
+  border-radius: 8px;
+  background: rgba(236, 239, 241, 0.35);
+}
+</style>

+ 80 - 0
migrations/versions/20260729_270_governance_responsibilities.py

@@ -0,0 +1,80 @@
+"""Add versioned and audited governance responsibility matrices."""
+
+from alembic import op
+
+
+revision = "20260729_270"
+down_revision = "20260724_260"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE IF NOT EXISTS public.governance_responsibility_scopes (
+            id UUID PRIMARY KEY,
+            resource_type VARCHAR(40) NOT NULL
+                CHECK (
+                    resource_type IN (
+                        'business_domain','device_asset','device_ontology',
+                        'device_mapping','fault_classification','quality_issue'
+                    )
+                ),
+            resource_uid VARCHAR(120) NOT NULL,
+            revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
+            updated_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (resource_type, resource_uid)
+        );
+
+        CREATE TABLE IF NOT EXISTS public.governance_responsibility_assignments (
+            id UUID PRIMARY KEY,
+            scope_id UUID NOT NULL
+                REFERENCES public.governance_responsibility_scopes(id)
+                ON DELETE CASCADE,
+            user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
+            responsibility_role VARCHAR(32) NOT NULL
+                CHECK (
+                    responsibility_role IN (
+                        'domain_owner','data_steward',
+                        'data_architect','asset_manager'
+                    )
+                ),
+            raci_role VARCHAR(16) NOT NULL
+                CHECK (
+                    raci_role IN (
+                        'responsible','accountable','consulted','informed'
+                    )
+                ),
+            assigned_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            assigned_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (scope_id, user_id, responsibility_role, raci_role)
+        );
+        CREATE INDEX IF NOT EXISTS idx_governance_responsibility_user
+            ON public.governance_responsibility_assignments(user_id);
+
+        CREATE TABLE IF NOT EXISTS public.governance_responsibility_audit_events (
+            id BIGSERIAL PRIMARY KEY,
+            scope_id UUID REFERENCES public.governance_responsibility_scopes(id)
+                ON DELETE SET NULL,
+            resource_type VARCHAR(40) NOT NULL,
+            resource_uid VARCHAR(120) NOT NULL,
+            actor_uid UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            action VARCHAR(40) NOT NULL,
+            before_state JSONB NOT NULL,
+            after_state JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX IF NOT EXISTS idx_governance_responsibility_audit_scope
+            ON public.governance_responsibility_audit_events(
+                resource_type, resource_uid, created_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Responsibility history is retained during application rollback.
+    pass

+ 124 - 0
tests/core/governance/test_responsibilities.py

@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+import pytest
+
+
+USER_A = "01900000-0000-7000-8000-000000000101"
+USER_B = "01900000-0000-7000-8000-000000000102"
+
+
+def test_device_scope_requires_one_accountable_asset_manager():
+    from app.core.governance.responsibilities import (
+        ResponsibilityValidationError,
+        validate_matrix,
+    )
+
+    with pytest.raises(
+        ResponsibilityValidationError,
+        match="one accountable asset manager",
+    ):
+        validate_matrix(
+            "device_asset",
+            [
+                {
+                    "user_id": USER_A,
+                    "responsibility_role": "data_steward",
+                    "raci_role": "responsible",
+                }
+            ],
+        )
+
+    validated = validate_matrix(
+        "device_asset",
+        [
+            {
+                "user_id": USER_A,
+                "responsibility_role": "asset_manager",
+                "raci_role": "accountable",
+            },
+            {
+                "user_id": USER_B,
+                "responsibility_role": "data_steward",
+                "raci_role": "responsible",
+            },
+        ],
+    )
+
+    assert [item.user_id for item in validated] == [USER_A, USER_B]
+
+
+def test_responsibility_matrix_rejects_duplicates_and_unknown_values():
+    from app.core.governance.responsibilities import (
+        ResponsibilityValidationError,
+        validate_matrix,
+    )
+
+    duplicate = {
+        "user_id": USER_A,
+        "responsibility_role": "asset_manager",
+        "raci_role": "accountable",
+    }
+    with pytest.raises(ResponsibilityValidationError, match="duplicate"):
+        validate_matrix("device_mapping", [duplicate, duplicate])
+
+    with pytest.raises(ResponsibilityValidationError, match="resource type"):
+        validate_matrix("dashboard", [duplicate])
+
+    with pytest.raises(ResponsibilityValidationError, match="responsibility role"):
+        validate_matrix(
+            "business_domain",
+            [
+                {
+                    "user_id": USER_A,
+                    "responsibility_role": "supervisor",
+                    "raci_role": "accountable",
+                }
+            ],
+        )
+
+
+def test_replace_is_revision_guarded_and_rejects_inactive_users():
+    from app.core.governance.responsibilities import (
+        ResponsibilityConflict,
+        ResponsibilityService,
+        ResponsibilityUserUnavailable,
+    )
+
+    class Repository:
+        def replace(self, **kwargs):
+            assert kwargs["expected_revision"] == 3
+            if kwargs["assignments"][0].user_id == USER_B:
+                raise ResponsibilityUserUnavailable("inactive user")
+            raise ResponsibilityConflict("revision conflict")
+
+    service = ResponsibilityService(Repository())
+
+    with pytest.raises(ResponsibilityConflict):
+        service.replace(
+            resource_type="device_ontology",
+            resource_uid="device-domain",
+            assignments=[
+                {
+                    "user_id": USER_A,
+                    "responsibility_role": "asset_manager",
+                    "raci_role": "accountable",
+                }
+            ],
+            expected_revision=3,
+            actor_uid=USER_A,
+        )
+
+    with pytest.raises(ResponsibilityUserUnavailable):
+        service.replace(
+            resource_type="device_ontology",
+            resource_uid="device-domain",
+            assignments=[
+                {
+                    "user_id": USER_B,
+                    "responsibility_role": "asset_manager",
+                    "raci_role": "accountable",
+                }
+            ],
+            expected_revision=3,
+            actor_uid=USER_A,
+        )

+ 143 - 0
tests/integration/test_responsibility_postgres.py

@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+import os
+import uuid
+
+import pytest
+from sqlalchemy import create_engine, text
+
+
+pytestmark = pytest.mark.integration
+
+
+def _login(client, username: str, password: str):
+    response = client.post(
+        "/api/system/auth/login",
+        json={"username": username, "password": password},
+    )
+    return response, (response.get_json().get("data") or {}).get("token")
+
+
+def test_device_responsibility_matrix_is_versioned_audited_and_readable(
+    monkeypatch,
+):
+    database_url = os.environ.get("TEST_DATABASE_URL")
+    admin_password = os.environ.get("TEST_ADMIN_PASSWORD")
+    if not database_url or not admin_password:
+        pytest.skip("TEST_DATABASE_URL and TEST_ADMIN_PASSWORD are required")
+
+    monkeypatch.setenv("DATABASE_URL", database_url)
+    monkeypatch.setenv("SECRET_KEY", "dataops-local-test-secret-key")
+    from app import create_app
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    client = app.test_client()
+    username = f"asset_manager_{uuid.uuid4().hex[:10]}"
+    resource_uid = f"device-{uuid.uuid4().hex[:12]}"
+    user_id = None
+    engine = create_engine(database_url)
+    try:
+        login, admin_token = _login(client, "admin", admin_password)
+        assert login.status_code == 200
+        assert admin_token
+
+        created = client.post(
+            "/api/system/users",
+            json={
+                "username": username,
+                "display_name": "设备资产管理员",
+                "password": "AssetManager123",
+                "roles": ["viewer"],
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert created.status_code == 201
+        user_id = created.get_json()["data"]["id"]
+
+        matrix = {
+            "assignments": [
+                {
+                    "user_id": user_id,
+                    "responsibility_role": "asset_manager",
+                    "raci_role": "accountable",
+                }
+            ]
+        }
+        updated = client.put(
+            f"/api/system/responsibilities/device_asset/{resource_uid}",
+            json=matrix,
+            headers={
+                "Authorization": f"Bearer {admin_token}",
+                "If-Match": '"0"',
+            },
+        )
+        assert updated.status_code == 200
+        assert updated.headers["ETag"] == '"1"'
+
+        viewer_login, viewer_token = _login(
+            client,
+            username,
+            "AssetManager123",
+        )
+        assert viewer_login.status_code == 200
+        readable = client.get(
+            f"/api/system/responsibilities/device_asset/{resource_uid}",
+            headers={"Authorization": f"Bearer {viewer_token}"},
+        )
+        assert readable.status_code == 200
+        assert readable.get_json()["data"]["assignments"][0]["user_id"] == user_id
+
+        forbidden = client.put(
+            f"/api/system/responsibilities/device_asset/{resource_uid}",
+            json=matrix,
+            headers={
+                "Authorization": f"Bearer {viewer_token}",
+                "If-Match": '"1"',
+            },
+        )
+        assert forbidden.status_code == 403
+
+        stale = client.put(
+            f"/api/system/responsibilities/device_asset/{resource_uid}",
+            json=matrix,
+            headers={
+                "Authorization": f"Bearer {admin_token}",
+                "If-Match": '"0"',
+            },
+        )
+        assert stale.status_code == 409
+
+        with engine.connect() as connection:
+            audit_count = connection.execute(
+                text(
+                    """
+                    SELECT COUNT(*)
+                    FROM public.governance_responsibility_audit_events
+                    WHERE resource_type = 'device_asset'
+                      AND resource_uid = :resource_uid
+                    """
+                ),
+                {"resource_uid": resource_uid},
+            ).scalar_one()
+        assert audit_count == 1
+    finally:
+        with engine.begin() as connection:
+            connection.execute(
+                text(
+                    """
+                    DELETE FROM public.governance_responsibility_scopes
+                    WHERE resource_type = 'device_asset'
+                      AND resource_uid = :resource_uid
+                    """
+                ),
+                {"resource_uid": resource_uid},
+            )
+            if user_id:
+                connection.execute(
+                    text(
+                        "DELETE FROM public.users WHERE id = CAST(:user_id AS uuid)"
+                    ),
+                    {"user_id": user_id},
+                )
+        engine.dispose()

+ 5 - 1
tests/knowledge/test_access_context.py

@@ -27,7 +27,11 @@ def test_viewer_access_context_uses_only_server_side_domain_grants():
 
     assert context.business_domain_uids == frozenset({"domain-b"})
     assert context.permissions == frozenset(
-        {"governance:read", "rules:read"}
+        {
+            "governance:read",
+            "governance:responsibilities:read",
+            "rules:read",
+        }
     )
 
 

+ 3 - 0
tests/test_database_migrations.py

@@ -48,6 +48,9 @@ EXPECTED_UPGRADED_TABLES = {
     "ontology_domain_links",
     "ontology_change_sets",
     "ontology_publish_runs",
+    "governance_responsibility_scopes",
+    "governance_responsibility_assignments",
+    "governance_responsibility_audit_events",
 }
 
 

+ 2 - 0
tests/test_permission_matrix.py

@@ -13,10 +13,12 @@ def test_fixed_role_permission_matrix_is_monotonic():
     assert "ingestion:run" in editor
     assert "data-elements:edit" in editor
     assert "ontologies:edit" in editor
+    assert "governance:responsibilities:read" in viewer
     assert "ingestion:admin" in admin
     assert "evidence:download" in admin
     assert "data-elements:publish" in admin
     assert "ontologies:publish" in admin
+    assert "governance:responsibilities:manage" in admin
 
 
 def test_data_development_paths_have_specific_write_policies():

+ 108 - 0
tests/test_responsibility_api.py

@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+
+USER_A = "01900000-0000-7000-8000-000000000101"
+
+
+class FakeService:
+    def __init__(self):
+        self.calls = []
+
+    def get(self, resource_type, resource_uid):
+        self.calls.append(("get", resource_type, resource_uid))
+        return {
+            "resource_type": resource_type,
+            "resource_uid": resource_uid,
+            "revision": 2,
+            "assignments": [],
+        }
+
+    def replace(self, **kwargs):
+        self.calls.append(("replace", kwargs))
+        return {
+            "resource_type": kwargs["resource_type"],
+            "resource_uid": kwargs["resource_uid"],
+            "revision": kwargs["expected_revision"] + 1,
+            "assignments": [
+                {
+                    **item,
+                    "username": "asset_admin",
+                    "display_name": "设备资产管理员",
+                }
+                for item in kwargs["assignments"]
+            ],
+        }
+
+
+def _headers(role: str, **extra):
+    return {"Authorization": f"Bearer {role}", **extra}
+
+
+def test_responsibility_matrix_is_readable_but_only_admin_can_replace(monkeypatch):
+    from app import create_app
+    from app.api.system import responsibilities
+
+    service = FakeService()
+    monkeypatch.setattr(responsibilities, "_service", lambda: service)
+    monkeypatch.setattr(
+        "app.core.system.auth.load_identity_from_token",
+        lambda token, secret: (
+            {"id": USER_A, "username": token, "roles": [token]}
+            if token in {"viewer", "editor", "admin"}
+            else None
+        ),
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    client = app.test_client()
+
+    readable = client.get(
+        "/api/system/responsibilities/device_asset/device-1",
+        headers=_headers("viewer"),
+    )
+    assert readable.status_code == 200
+    assert readable.headers["ETag"] == '"2"'
+
+    payload = {
+        "assignments": [
+            {
+                "user_id": USER_A,
+                "responsibility_role": "asset_manager",
+                "raci_role": "accountable",
+            }
+        ]
+    }
+    forbidden = client.put(
+        "/api/system/responsibilities/device_asset/device-1",
+        json=payload,
+        headers=_headers("editor", **{"If-Match": '"2"'}),
+    )
+    assert forbidden.status_code == 403
+
+    missing_revision = client.put(
+        "/api/system/responsibilities/device_asset/device-1",
+        json=payload,
+        headers=_headers("admin"),
+    )
+    assert missing_revision.status_code == 428
+
+    updated = client.put(
+        "/api/system/responsibilities/device_asset/device-1",
+        json=payload,
+        headers=_headers("admin", **{"If-Match": '"2"'}),
+    )
+    assert updated.status_code == 200
+    assert updated.headers["ETag"] == '"3"'
+    assert service.calls[-1][1]["actor_uid"] == USER_A
+
+
+def test_responsibility_paths_have_dedicated_permissions():
+    from app.core.system.permissions import (
+        RESPONSIBILITIES_MANAGE,
+        RESPONSIBILITIES_READ,
+        permission_for_request,
+    )
+
+    path = "/api/system/responsibilities/device_asset/device-1"
+    assert permission_for_request(path, "GET") == (RESPONSIBILITIES_READ,)
+    assert permission_for_request(path, "PUT") == (RESPONSIBILITIES_MANAGE,)

+ 30 - 0
tests/test_responsibility_frontend_contract.py

@@ -0,0 +1,30 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_responsibility_matrix_has_admin_route_and_versioned_client():
+    routes = (ROOT / "frontend/src/router/routes.js").read_text(encoding="utf-8")
+    api = (
+        ROOT / "frontend/src/api/responsibilities.js"
+    ).read_text(encoding="utf-8")
+    view = (
+        ROOT / "frontend/src/views/systemManage/responsibility/index.vue"
+    ).read_text(encoding="utf-8")
+    request = (
+        ROOT / "frontend/src/utils/request.js"
+    ).read_text(encoding="utf-8")
+
+    assert "/systemManage/responsibilities" in routes
+    assert "governance:responsibilities:manage" in routes
+    assert "getResponsibilityMatrix" in api
+    assert "replaceResponsibilityMatrix" in api
+    assert "'If-Match': `\"${revision}\"`" in api
+    assert "put (url, params, config = {})" in request
+    assert "设备责任矩阵" in view
+    assert "平台角色决定功能权限" in view
+    assert "设备资产管理员" in view
+    assert "责任类型" in view
+    assert "RACI" in view
+    assert "accountable" in view

+ 27 - 0
tests/test_responsibility_schema.py

@@ -0,0 +1,27 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_responsibility_migration_is_audited_and_data_preserving():
+    migration = (
+        ROOT
+        / "migrations"
+        / "versions"
+        / "20260729_270_governance_responsibilities.py"
+    ).read_text(encoding="utf-8")
+
+    assert 'revision = "20260729_270"' in migration
+    assert 'down_revision = "20260724_260"' in migration
+    for table in (
+        "governance_responsibility_scopes",
+        "governance_responsibility_assignments",
+        "governance_responsibility_audit_events",
+    ):
+        assert f"CREATE TABLE IF NOT EXISTS public.{table}" in migration
+    assert "responsibility_role" in migration
+    assert "raci_role" in migration
+    assert "before_state JSONB" in migration
+    assert "after_state JSONB" in migration
+    assert "DROP TABLE" not in migration.upper()