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

feat: add reversible device entity resolution

马小龙 3 недель назад
Родитель
Сommit
dff99c5eb9
31 измененных файлов с 5702 добавлено и 20 удалено
  1. 329 1
      app/api/data_development/routes.py
  2. 3 0
      app/config/config.py
  3. 295 0
      app/core/data_research/device_entity_repository.py
  4. 737 0
      app/core/data_research/device_entity_resolution.py
  5. 20 0
      app/core/data_research/errors.py
  6. 11 0
      app/core/system/permissions.py
  7. 205 1
      app/models/data_research.py
  8. 329 1
      deployment/app/api/data_development/routes.py
  9. 53 4
      deployment/app/config/config.py
  10. 295 0
      deployment/app/core/data_research/device_entity_repository.py
  11. 737 0
      deployment/app/core/data_research/device_entity_resolution.py
  12. 20 0
      deployment/app/core/data_research/errors.py
  13. 11 0
      deployment/app/core/system/permissions.py
  14. 205 1
      deployment/app/models/data_research.py
  15. 1 0
      docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md
  16. 12 10
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  17. 6 0
      docs/architecture/DATA_MODEL.md
  18. 235 1
      docs/architecture/OPENAPI.yaml
  19. 203 0
      docs/superpowers/plans/2026-07-29-wp06-device-entity-resolution.md
  20. 43 1
      frontend/src/api/dataDevelopment.js
  21. 12 0
      frontend/src/router/routes.js
  22. 671 0
      frontend/src/views/dataGovernance/development/deviceEntityResolution.vue
  23. 73 0
      frontend/src/views/dataGovernance/development/deviceEntityResolutionModel.js
  24. 1 0
      frontend/src/views/dataGovernance/development/index.vue
  25. 55 0
      frontend/tests/device-entity-resolution-model.test.mjs
  26. 119 0
      migrations/versions/20260729_310_device_entity_resolution.py
  27. 485 0
      tests/data_research/test_device_entity_resolution.py
  28. 310 0
      tests/data_research/test_device_entity_resolution_api.py
  29. 181 0
      tests/integration/test_device_entity_resolution_postgres.py
  30. 28 0
      tests/test_database_migrations.py
  31. 17 0
      tests/test_permission_matrix.py

+ 329 - 1
app/api/data_development/routes.py

@@ -12,7 +12,6 @@ from app.api.data_development import bp
 from app.core.data_research.errors import DataResearchError
 from app.models.result import failed, success
 
-
 logger = logging.getLogger(__name__)
 
 
@@ -166,6 +165,53 @@ def get_device_semantic_code_service():
     )
 
 
+def get_device_entity_resolution_service():
+    from app.core.data_research.device_entity_repository import (
+        SqlAlchemyDeviceEntityResolutionRepository,
+    )
+    from app.core.data_research.device_entity_resolution import (
+        DeviceEntityForbidden,
+        DeviceEntityResolutionService,
+    )
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+
+    def assert_accountable(actor_uid):
+        matrix = responsibilities.get(
+            "device_mapping",
+            "DEVICE_ENTITY_RESOLUTION",
+        )
+        accountable = [
+            item
+            for item in matrix.get("assignments", [])
+            if item.get("responsibility_role") == "asset_manager"
+            and item.get("raci_role") == "accountable"
+        ]
+        if len(accountable) != 1 or str(accountable[0].get("user_id")) != str(
+            actor_uid
+        ):
+            raise DeviceEntityForbidden(
+                "only the accountable device mapping asset manager may decide"
+            )
+
+    return DeviceEntityResolutionService(
+        SqlAlchemyDeviceEntityResolutionRepository(db.session),
+        review_authorizer=assert_accountable,
+        auto_merge_enabled=current_app.config.get(
+            "DEVICE_ENTITY_AUTO_MERGE_ENABLED",
+            False,
+        ),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
 def get_candidate_decision_service():
     from app.core.data_research.candidate_decisions import CandidateDecisionService
     from app.core.data_research.data_elements import DataElementService
@@ -536,6 +582,89 @@ def _device_semantic_review(record):
     }
 
 
+def _device_entity_candidate(record):
+    return {
+        "uid": str(record.uid),
+        "left_asset_uid": str(record.left_asset_uid),
+        "right_asset_uid": str(record.right_asset_uid),
+        "canonical_asset_uid": (
+            str(record.canonical_asset_uid)
+            if record.canonical_asset_uid
+            else None
+        ),
+        "status": record.status,
+        "suggestion_source": record.suggestion_source,
+        "confidence": float(record.confidence),
+        "explanation": [
+            dict(item) for item in record.explanation
+        ],
+        "evidence_uids": list(record.evidence_uids),
+        "model_provider": record.model_provider,
+        "model_name": record.model_name,
+        "current_version": int(record.current_version),
+        "created_by": record.created_by,
+        "reviewed_by": record.reviewed_by,
+        "created_at": _iso(record.created_at),
+        "updated_at": _iso(record.updated_at),
+    }
+
+
+def _device_entity_review(record):
+    return {
+        "uid": str(record.uid),
+        "candidate_uid": str(record.candidate_uid),
+        "version": int(record.version),
+        "decision": record.decision,
+        "reason": record.reason,
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_entity_merge(record):
+    return {
+        "uid": str(record.uid),
+        "candidate_uid": str(record.candidate_uid),
+        "canonical_asset_uid": str(record.canonical_asset_uid),
+        "member_asset_uid": str(record.member_asset_uid),
+        "review_uid": str(record.review_uid),
+        "snapshot": dict(record.snapshot),
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_entity_rollback(record):
+    return {
+        "uid": str(record.uid),
+        "merge_uid": str(record.merge_uid),
+        "candidate_uid": str(record.candidate_uid),
+        "reason": record.reason,
+        "snapshot": dict(record.snapshot),
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_entity_generation(result):
+    return {
+        "records": [
+            _device_entity_candidate(record)
+            for record in result.records
+        ],
+        "created_count": int(result.created_count),
+        "existing_count": int(result.existing_count),
+        "evaluated_pair_count": int(result.evaluated_pair_count),
+        "auto_merged_count": int(result.auto_merged_count),
+        "auto_merge_enabled": bool(
+            current_app.config.get(
+                "DEVICE_ENTITY_AUTO_MERGE_ENABLED",
+                False,
+            )
+        ),
+    }
+
+
 def _device_asset_page(name, *, default, maximum):
     from app.core.data_research.errors import DeviceAssetInvalid
 
@@ -965,6 +1094,205 @@ def list_device_semantic_code_reviews(code_uid):
         return _error(error)
 
 
+@bp.route("/device-entities/candidates", methods=["GET"])
+def list_device_entity_candidates():
+    filters = {
+        name: request.args.get(name)
+        for name in ("status", "suggestion_source")
+        if request.args.get(name)
+    }
+    try:
+        records, total = get_device_entity_resolution_service().search(
+            filters,
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_candidate(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(request.args.get("page", 1)),
+                    "page_size": int(request.args.get("page_size", 20)),
+                    "auto_merge_enabled": bool(
+                        current_app.config.get(
+                            "DEVICE_ENTITY_AUTO_MERGE_ENABLED",
+                            False,
+                        )
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-entities/candidates/generate", methods=["POST"])
+def generate_device_entity_candidates():
+    try:
+        result = get_device_entity_resolution_service().generate(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        status = 201 if result.created_count else 200
+        return jsonify(success(_device_entity_generation(result))), status
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-entities/candidates", methods=["POST"])
+def submit_device_entity_candidate():
+    try:
+        record = (
+            get_device_entity_resolution_service().submit_ai_candidate(
+                request.get_json(silent=True) or {},
+                actor_uid=_identity().get("id") or _identity().get("sub"),
+            )
+        )
+        return jsonify(success(_device_entity_candidate(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-entities/candidates/<candidate_uid>", methods=["GET"])
+def get_device_entity_candidate(candidate_uid):
+    try:
+        record = get_device_entity_resolution_service().get(candidate_uid)
+        return jsonify(success(_device_entity_candidate(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/candidates/<candidate_uid>/review",
+    methods=["POST"],
+)
+def review_device_entity_candidate(candidate_uid):
+    try:
+        candidate, review, merge = (
+            get_device_entity_resolution_service().review(
+                candidate_uid,
+                request.get_json(silent=True) or {},
+                actor_uid=_identity().get("id") or _identity().get("sub"),
+            )
+        )
+        return jsonify(
+            success(
+                {
+                    "candidate": _device_entity_candidate(candidate),
+                    "review": _device_entity_review(review),
+                    "merge": (
+                        _device_entity_merge(merge)
+                        if merge is not None
+                        else None
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/candidates/<candidate_uid>/reviews",
+    methods=["GET"],
+)
+def list_device_entity_reviews(candidate_uid):
+    try:
+        records = get_device_entity_resolution_service().reviews(
+            candidate_uid
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_review(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/candidates/<candidate_uid>/merges",
+    methods=["GET"],
+)
+def list_device_entity_merges(candidate_uid):
+    try:
+        records = get_device_entity_resolution_service().merges(
+            candidate_uid
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_merge(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/merges/<merge_uid>/rollback",
+    methods=["POST"],
+)
+def rollback_device_entity_merge(merge_uid):
+    try:
+        candidate, rollback = (
+            get_device_entity_resolution_service().rollback(
+                merge_uid,
+                request.get_json(silent=True) or {},
+                actor_uid=_identity().get("id") or _identity().get("sub"),
+            )
+        )
+        return jsonify(
+            success(
+                {
+                    "candidate": _device_entity_candidate(candidate),
+                    "rollback": _device_entity_rollback(rollback),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/merges/<merge_uid>/rollbacks",
+    methods=["GET"],
+)
+def list_device_entity_rollbacks(merge_uid):
+    try:
+        records = get_device_entity_resolution_service().rollbacks(
+            merge_uid
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_rollback(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

+ 3 - 0
app/config/config.py

@@ -493,6 +493,9 @@ class BaseConfig:
     DATA_FACTORY_ACTIVATION_ENABLED = get_bool_env(
         "DATA_FACTORY_ACTIVATION_ENABLED", False
     )
+    DEVICE_ENTITY_AUTO_MERGE_ENABLED = get_bool_env(
+        "DEVICE_ENTITY_AUTO_MERGE_ENABLED", False
+    )
 
     # DataOps 平台 API 基础 URL(用于 n8n 工作流回调等)
     API_BASE_URL = os.environ.get("API_BASE_URL", "http://127.0.0.1:5500/api")

+ 295 - 0
app/core/data_research/device_entity_repository.py

@@ -0,0 +1,295 @@
+"""SQLAlchemy persistence for governed device entity resolution."""
+
+from __future__ import annotations
+
+from sqlalchemy import func
+
+from app.core.data_research.device_asset_repository import (
+    SqlAlchemyDeviceAssetRepository,
+)
+from app.core.data_research.device_entity_resolution import (
+    DeviceEntityCandidateRecord,
+    DeviceEntityMergeRecord,
+    DeviceEntityReviewRecord,
+    DeviceEntityRollbackRecord,
+)
+from app.models.data_research import (
+    DeviceAsset,
+    DeviceEntityMatchCandidate,
+    DeviceEntityMatchReview,
+    DeviceEntityMergeEvent,
+    DeviceEntityMergeRollback,
+)
+
+
+class SqlAlchemyDeviceEntityResolutionRepository:
+    def __init__(self, session):
+        self.session = session
+        self.assets = SqlAlchemyDeviceAssetRepository(session)
+
+    @staticmethod
+    def _candidate(model):
+        return DeviceEntityCandidateRecord(
+            uid=str(model.uid),
+            left_asset_uid=str(model.left_asset_uid),
+            right_asset_uid=str(model.right_asset_uid),
+            canonical_asset_uid=(
+                str(model.canonical_asset_uid)
+                if model.canonical_asset_uid
+                else None
+            ),
+            status=model.status,
+            suggestion_source=model.suggestion_source,
+            confidence=float(model.confidence),
+            explanation=tuple(
+                dict(item) for item in (model.explanation or [])
+            ),
+            evidence_uids=tuple(model.evidence_uids or []),
+            model_provider=model.model_provider,
+            model_name=model.model_name,
+            current_version=int(model.current_version),
+            created_by=model.created_by,
+            reviewed_by=model.reviewed_by,
+            created_at=model.created_at,
+            updated_at=model.updated_at,
+        )
+
+    @staticmethod
+    def _review(model):
+        return DeviceEntityReviewRecord(
+            uid=str(model.uid),
+            candidate_uid=str(model.candidate_uid),
+            version=int(model.version),
+            decision=model.decision,
+            reason=model.reason,
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _merge(model):
+        return DeviceEntityMergeRecord(
+            uid=str(model.uid),
+            candidate_uid=str(model.candidate_uid),
+            canonical_asset_uid=str(model.canonical_asset_uid),
+            member_asset_uid=str(model.member_asset_uid),
+            review_uid=str(model.review_uid),
+            snapshot=dict(model.snapshot or {}),
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _rollback(model):
+        return DeviceEntityRollbackRecord(
+            uid=str(model.uid),
+            merge_uid=str(model.merge_uid),
+            candidate_uid=str(model.candidate_uid),
+            reason=model.reason,
+            snapshot=dict(model.snapshot or {}),
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    def get_asset_detail(self, uid):
+        asset = self.assets.get(str(uid))
+        if asset is None:
+            return None
+        from app.core.data_research.device_assets import DeviceAssetDetail
+
+        return DeviceAssetDetail(
+            asset=asset,
+            mappings=tuple(self.assets.list_mappings(asset.uid)),
+        )
+
+    def list_matchable_assets(self, asset_type, *, limit):
+        rows = (
+            self.session.query(DeviceAsset)
+            .filter_by(asset_type=str(asset_type), status="active")
+            .order_by(DeviceAsset.uid.asc())
+            .limit(int(limit))
+            .all()
+        )
+        return [
+            self.get_asset_detail(str(model.uid))
+            for model in rows
+        ]
+
+    def find_open_pair(self, left_uid, right_uid):
+        model = (
+            self.session.query(DeviceEntityMatchCandidate)
+            .filter_by(
+                left_asset_uid=str(left_uid),
+                right_asset_uid=str(right_uid),
+            )
+            .filter(
+                DeviceEntityMatchCandidate.status.in_(("pending", "merged"))
+            )
+            .first()
+        )
+        return self._candidate(model) if model is not None else None
+
+    @staticmethod
+    def _candidate_model(record):
+        return DeviceEntityMatchCandidate(
+            uid=record.uid,
+            left_asset_uid=record.left_asset_uid,
+            right_asset_uid=record.right_asset_uid,
+            canonical_asset_uid=record.canonical_asset_uid,
+            status=record.status,
+            suggestion_source=record.suggestion_source,
+            confidence=record.confidence,
+            explanation=list(record.explanation),
+            evidence_uids=list(record.evidence_uids),
+            model_provider=record.model_provider,
+            model_name=record.model_name,
+            current_version=record.current_version,
+            created_by=record.created_by,
+            reviewed_by=record.reviewed_by,
+            created_at=record.created_at,
+            updated_at=record.updated_at,
+        )
+
+    def create_candidate(self, record):
+        model = self._candidate_model(record)
+        self.session.add(model)
+        self.session.flush()
+        return self._candidate(model)
+
+    def search_candidates(self, filters, *, page, page_size):
+        query = self.session.query(DeviceEntityMatchCandidate)
+        if filters.get("status"):
+            query = query.filter_by(status=str(filters["status"]))
+        if filters.get("suggestion_source"):
+            query = query.filter_by(
+                suggestion_source=str(filters["suggestion_source"])
+            )
+        total = query.with_entities(func.count()).scalar() or 0
+        rows = (
+            query.order_by(
+                DeviceEntityMatchCandidate.updated_at.desc(),
+                DeviceEntityMatchCandidate.uid.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._candidate(model) for model in rows], int(total)
+
+    def get_candidate(self, uid, *, for_update=False):
+        query = self.session.query(DeviceEntityMatchCandidate).filter_by(
+            uid=str(uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._candidate(model) if model is not None else None
+
+    def update_candidate(self, record):
+        model = self.session.get(DeviceEntityMatchCandidate, str(record.uid))
+        for name in (
+            "canonical_asset_uid",
+            "status",
+            "current_version",
+            "reviewed_by",
+            "updated_at",
+        ):
+            setattr(model, name, getattr(record, name))
+        self.session.flush()
+        return self._candidate(model)
+
+    def append_review(self, record):
+        model = DeviceEntityMatchReview(
+            uid=record.uid,
+            candidate_uid=record.candidate_uid,
+            version=record.version,
+            decision=record.decision,
+            reason=record.reason,
+            actor_uid=record.actor_uid,
+            created_at=record.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._review(model)
+
+    def active_merge_for_member(self, asset_uid):
+        model = (
+            self.session.query(DeviceEntityMergeEvent)
+            .outerjoin(
+                DeviceEntityMergeRollback,
+                DeviceEntityMergeRollback.merge_uid
+                == DeviceEntityMergeEvent.uid,
+            )
+            .filter(
+                DeviceEntityMergeEvent.member_asset_uid == str(asset_uid),
+                DeviceEntityMergeRollback.uid.is_(None),
+            )
+            .order_by(DeviceEntityMergeEvent.created_at.desc())
+            .first()
+        )
+        return self._merge(model) if model is not None else None
+
+    def create_merge(self, record):
+        model = DeviceEntityMergeEvent(
+            uid=record.uid,
+            candidate_uid=record.candidate_uid,
+            canonical_asset_uid=record.canonical_asset_uid,
+            member_asset_uid=record.member_asset_uid,
+            review_uid=record.review_uid,
+            snapshot=record.snapshot,
+            actor_uid=record.actor_uid,
+            created_at=record.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._merge(model)
+
+    def get_merge(self, uid, *, for_update=False):
+        query = self.session.query(DeviceEntityMergeEvent).filter_by(
+            uid=str(uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._merge(model) if model is not None else None
+
+    def create_rollback(self, record):
+        model = DeviceEntityMergeRollback(
+            uid=record.uid,
+            merge_uid=record.merge_uid,
+            candidate_uid=record.candidate_uid,
+            reason=record.reason,
+            snapshot=record.snapshot,
+            actor_uid=record.actor_uid,
+            created_at=record.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._rollback(model)
+
+    def list_reviews(self, candidate_uid):
+        return [
+            self._review(model)
+            for model in self.session.query(DeviceEntityMatchReview)
+            .filter_by(candidate_uid=str(candidate_uid))
+            .order_by(DeviceEntityMatchReview.created_at.desc())
+            .all()
+        ]
+
+    def list_merges(self, candidate_uid):
+        return [
+            self._merge(model)
+            for model in self.session.query(DeviceEntityMergeEvent)
+            .filter_by(candidate_uid=str(candidate_uid))
+            .order_by(DeviceEntityMergeEvent.created_at.desc())
+            .all()
+        ]
+
+    def list_rollbacks(self, merge_uid):
+        return [
+            self._rollback(model)
+            for model in self.session.query(DeviceEntityMergeRollback)
+            .filter_by(merge_uid=str(merge_uid))
+            .order_by(DeviceEntityMergeRollback.created_at.desc())
+            .all()
+        ]

+ 737 - 0
app/core/data_research/device_entity_resolution.py

@@ -0,0 +1,737 @@
+"""Governed, explainable, and reversible cross-source device resolution."""
+
+from __future__ import annotations
+
+import unicodedata
+from collections.abc import Callable
+from dataclasses import dataclass, replace
+from datetime import datetime
+from itertools import combinations
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+from app.core.data_research.device_assets import ASSET_TYPES, DeviceAssetDetail
+from app.core.data_research.errors import (
+    DeviceEntityConflict,
+    DeviceEntityForbidden,
+    DeviceEntityInvalid,
+    DeviceEntityNotFound,
+)
+
+MAX_MATCH_ASSETS = 500
+MAX_NEW_CANDIDATES = 2_000
+MAX_PAGE_SIZE = 100
+AUTO_MERGE_THRESHOLD = 0.98
+SIGNAL_WEIGHTS = (
+    ("name", 0.50),
+    ("location", 0.15),
+    ("organization", 0.10),
+    ("responsible_person", 0.05),
+    ("model", 0.15),
+    ("source_code", 0.05),
+)
+SECRET_KEYS = frozenset(
+    {
+        "password",
+        "passwd",
+        "credential",
+        "credentials",
+        "token",
+        "api_key",
+        "authorization",
+        "connection_string",
+        "connection_url",
+        "phone",
+        "mobile",
+        "email",
+        "contact_phone",
+        "contact_email",
+        "raw_measurements",
+        "measurements",
+    }
+)
+
+
+@dataclass(frozen=True)
+class DevicePairScore:
+    confidence: float
+    explanation: tuple[dict[str, Any], ...]
+
+
+@dataclass(frozen=True)
+class DeviceEntityCandidateRecord:
+    uid: str
+    left_asset_uid: str
+    right_asset_uid: str
+    canonical_asset_uid: str | None
+    status: str
+    suggestion_source: str
+    confidence: float
+    explanation: tuple[dict[str, Any], ...]
+    evidence_uids: tuple[str, ...]
+    model_provider: str | None
+    model_name: str | None
+    current_version: int
+    created_by: str | None
+    reviewed_by: str | None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityReviewRecord:
+    uid: str
+    candidate_uid: str
+    version: int
+    decision: str
+    reason: str
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityMergeRecord:
+    uid: str
+    candidate_uid: str
+    canonical_asset_uid: str
+    member_asset_uid: str
+    review_uid: str
+    snapshot: dict[str, Any]
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityRollbackRecord:
+    uid: str
+    merge_uid: str
+    candidate_uid: str
+    reason: str
+    snapshot: dict[str, Any]
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityGenerationResult:
+    records: tuple[DeviceEntityCandidateRecord, ...]
+    created_count: int
+    existing_count: int
+    evaluated_pair_count: int
+    auto_merged_count: int
+
+
+def _normalized(value: Any) -> str:
+    text = unicodedata.normalize("NFKC", str(value or "")).casefold()
+    return "".join(character for character in text if character.isalnum())
+
+
+def _mapping_value(detail: DeviceAssetDetail, name: str) -> str:
+    if name == "source_code":
+        values = sorted(
+            {
+                _normalized(mapping.source_code)
+                for mapping in detail.mappings
+                if _normalized(mapping.source_code)
+            }
+        )
+        return "|".join(values)
+    return ""
+
+
+def _signal_value(detail: DeviceAssetDetail, name: str) -> str:
+    if name == "model":
+        return _normalized((detail.asset.attributes or {}).get("model"))
+    if name == "source_code":
+        return _mapping_value(detail, name)
+    return _normalized(getattr(detail.asset, name, None))
+
+
+def score_device_pair(
+    left: DeviceAssetDetail,
+    right: DeviceAssetDetail,
+) -> DevicePairScore:
+    explanation = []
+    confidence = 0.0
+    for signal, weight in SIGNAL_WEIGHTS:
+        left_value = _signal_value(left, signal)
+        right_value = _signal_value(right, signal)
+        matched = bool(left_value and right_value and left_value == right_value)
+        if matched:
+            confidence += weight
+        explanation.append(
+            {
+                "signal": signal,
+                "matched": matched,
+                "weight": weight,
+                "left": left_value or None,
+                "right": right_value or None,
+            }
+        )
+    return DevicePairScore(
+        confidence=round(confidence, 6),
+        explanation=tuple(explanation),
+    )
+
+
+def _required_text(
+    payload: dict[str, Any],
+    name: str,
+    *,
+    maximum: int = 1_000,
+) -> str:
+    value = str(payload.get(name) or "").strip()
+    if not value:
+        raise DeviceEntityInvalid(f"{name} is required")
+    if len(value) > maximum:
+        raise DeviceEntityInvalid(f"{name} exceeds {maximum} characters")
+    return value
+
+
+def _bounded_float(payload, name, *, default=None):
+    raw = payload.get(name, default)
+    try:
+        value = float(raw)
+    except (TypeError, ValueError) as exc:
+        raise DeviceEntityInvalid(f"{name} must be a number") from exc
+    if value < 0 or value > 1:
+        raise DeviceEntityInvalid(f"{name} must be between 0 and 1")
+    return value
+
+
+def _positive_int(payload, name, *, default, maximum):
+    raw = payload.get(name, default)
+    try:
+        value = int(raw)
+    except (TypeError, ValueError) as exc:
+        raise DeviceEntityInvalid(f"{name} must be an integer") from exc
+    if value < 1 or value > maximum:
+        raise DeviceEntityInvalid(
+            f"{name} must be between 1 and {maximum}"
+        )
+    return value
+
+
+def _reject_sensitive(value: Any) -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if str(key).strip().lower() in SECRET_KEYS:
+                raise DeviceEntityInvalid(
+                    "sensitive fields are not allowed in entity resolution"
+                )
+            _reject_sensitive(item)
+    elif isinstance(value, (list, tuple)):
+        for item in value:
+            _reject_sensitive(item)
+
+
+def _evidence_uids(payload, *, required=True) -> tuple[str, ...]:
+    values = payload.get("evidence_uids")
+    if not isinstance(values, list):
+        raise DeviceEntityInvalid("evidence_uids must be an array")
+    records = tuple(
+        dict.fromkeys(
+            str(value).strip()
+            for value in values
+            if str(value).strip()
+        )
+    )
+    if required and not records:
+        raise DeviceEntityInvalid("evidence_uids are required")
+    if len(records) > 100 or any(len(item) > 300 for item in records):
+        raise DeviceEntityInvalid("evidence_uids exceed the governed boundary")
+    return records
+
+
+def _pair(left_uid: str, right_uid: str) -> tuple[str, str]:
+    left_uid = str(left_uid).strip()
+    right_uid = str(right_uid).strip()
+    if not left_uid or not right_uid or left_uid == right_uid:
+        raise DeviceEntityInvalid("candidate requires two distinct assets")
+    return tuple(sorted((left_uid, right_uid)))
+
+
+def _source_uids(detail: DeviceAssetDetail) -> set[str]:
+    return {str(mapping.source_uid) for mapping in detail.mappings}
+
+
+def _asset_snapshot(detail: DeviceAssetDetail) -> dict[str, Any]:
+    return {
+        "uid": detail.asset.uid,
+        "asset_type": detail.asset.asset_type,
+        "name": detail.asset.name,
+        "location": detail.asset.location,
+        "organization": detail.asset.organization,
+        "responsible_person": detail.asset.responsible_person,
+        "model": (detail.asset.attributes or {}).get("model"),
+        "source_mappings": [
+            {
+                "uid": mapping.uid,
+                "source_uid": mapping.source_uid,
+                "source_entity": mapping.source_entity,
+                "source_code": mapping.source_code,
+            }
+            for mapping in detail.mappings
+        ],
+    }
+
+
+class DeviceEntityResolutionService:
+    def __init__(
+        self,
+        repository,
+        *,
+        review_authorizer: Callable[[str], Any] = lambda _actor_uid: None,
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        auto_merge_enabled: bool = False,
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self.repository = repository
+        self.review_authorizer = review_authorizer
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.auto_merge_enabled = bool(auto_merge_enabled)
+        self.commit = commit
+        self.rollback_transaction = rollback
+
+    @staticmethod
+    def _validate_pair(left, right):
+        if left is None or right is None:
+            raise DeviceEntityNotFound("candidate asset was not found")
+        if left.asset.status != "active" or right.asset.status != "active":
+            raise DeviceEntityInvalid("candidate assets must be active")
+        if left.asset.asset_type != right.asset.asset_type:
+            raise DeviceEntityInvalid("candidate assets must have the same type")
+        if _source_uids(left) & _source_uids(right):
+            raise DeviceEntityInvalid(
+                "candidate assets must come from different source systems"
+            )
+
+    @staticmethod
+    def _rule_evidence(left, right):
+        return tuple(
+            dict.fromkeys(
+                (
+                    left.asset.uid,
+                    *(mapping.uid for mapping in left.mappings),
+                    right.asset.uid,
+                    *(mapping.uid for mapping in right.mappings),
+                )
+            )
+        )
+
+    def generate(self, payload, actor_uid) -> DeviceEntityGenerationResult:
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        asset_type = str(payload.get("asset_type") or "device").strip()
+        if asset_type not in ASSET_TYPES:
+            raise DeviceEntityInvalid("asset_type is not supported")
+        threshold = _bounded_float(payload, "threshold", default=0.7)
+        limit = _positive_int(
+            payload,
+            "limit",
+            default=MAX_MATCH_ASSETS,
+            maximum=MAX_MATCH_ASSETS,
+        )
+        details = self.repository.list_matchable_assets(
+            asset_type,
+            limit=limit,
+        )
+        records = []
+        created_count = 0
+        existing_count = 0
+        evaluated = 0
+        auto_merged_count = 0
+        try:
+            for first, second in combinations(details, 2):
+                if len(records) >= MAX_NEW_CANDIDATES:
+                    break
+                left_uid, right_uid = _pair(
+                    first.asset.uid,
+                    second.asset.uid,
+                )
+                left = first if first.asset.uid == left_uid else second
+                right = second if second.asset.uid == right_uid else first
+                try:
+                    self._validate_pair(left, right)
+                except DeviceEntityInvalid:
+                    continue
+                evaluated += 1
+                score = score_device_pair(left, right)
+                if score.confidence < threshold:
+                    continue
+                existing = self.repository.find_open_pair(
+                    left_uid,
+                    right_uid,
+                )
+                if existing is not None:
+                    records.append(existing)
+                    existing_count += 1
+                    continue
+                now = self.now_factory()
+                candidate = DeviceEntityCandidateRecord(
+                    uid=self.uid_factory(),
+                    left_asset_uid=left_uid,
+                    right_asset_uid=right_uid,
+                    canonical_asset_uid=None,
+                    status="pending",
+                    suggestion_source="rule",
+                    confidence=score.confidence,
+                    explanation=score.explanation,
+                    evidence_uids=self._rule_evidence(left, right),
+                    model_provider=None,
+                    model_name=None,
+                    current_version=1,
+                    created_by=actor_uid,
+                    reviewed_by=None,
+                    created_at=now,
+                    updated_at=now,
+                )
+                candidate = self.repository.create_candidate(candidate)
+                created_count += 1
+                if (
+                    self.auto_merge_enabled
+                    and candidate.confidence >= AUTO_MERGE_THRESHOLD
+                ):
+                    self.review_authorizer(actor_uid)
+                    candidate, _review, _merge = self._decide(
+                        candidate,
+                        decision="auto_approve",
+                        canonical_asset_uid=left_uid,
+                        reason="strict deterministic auto-merge threshold met",
+                        actor_uid=actor_uid,
+                    )
+                    auto_merged_count += 1
+                records.append(candidate)
+            self.commit()
+        except Exception:
+            self.rollback_transaction()
+            raise
+        return DeviceEntityGenerationResult(
+            records=tuple(records),
+            created_count=created_count,
+            existing_count=existing_count,
+            evaluated_pair_count=evaluated,
+            auto_merged_count=auto_merged_count,
+        )
+
+    def submit_ai_candidate(self, payload, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        _reject_sensitive(payload)
+        left_uid, right_uid = _pair(
+            payload.get("left_asset_uid"),
+            payload.get("right_asset_uid"),
+        )
+        left = self.repository.get_asset_detail(left_uid)
+        right = self.repository.get_asset_detail(right_uid)
+        self._validate_pair(left, right)
+        if self.repository.active_merge_for_member(left_uid) or (
+            self.repository.active_merge_for_member(right_uid)
+        ):
+            raise DeviceEntityConflict(
+                "candidate asset already belongs to an active merge"
+            )
+        if self.repository.find_open_pair(left_uid, right_uid):
+            raise DeviceEntityConflict("candidate pair already has an active merge")
+        confidence = _bounded_float(payload, "confidence")
+        provider = _required_text(payload, "model_provider", maximum=200)
+        model_name = _required_text(payload, "model_name", maximum=200)
+        explanation_text = _required_text(
+            payload,
+            "explanation",
+            maximum=2_000,
+        )
+        evidence = _evidence_uids(payload)
+        now = self.now_factory()
+        candidate = DeviceEntityCandidateRecord(
+            uid=self.uid_factory(),
+            left_asset_uid=left_uid,
+            right_asset_uid=right_uid,
+            canonical_asset_uid=None,
+            status="pending",
+            suggestion_source="ai",
+            confidence=confidence,
+            explanation=(
+                {
+                    "signal": "ai_explanation",
+                    "matched": True,
+                    "weight": confidence,
+                    "left": explanation_text,
+                    "right": None,
+                },
+            ),
+            evidence_uids=evidence,
+            model_provider=provider,
+            model_name=model_name,
+            current_version=1,
+            created_by=actor_uid,
+            reviewed_by=None,
+            created_at=now,
+            updated_at=now,
+        )
+        try:
+            candidate = self.repository.create_candidate(candidate)
+            self.commit()
+            return candidate
+        except Exception:
+            self.rollback_transaction()
+            raise
+
+    @staticmethod
+    def _version(value):
+        try:
+            version = int(value)
+        except (TypeError, ValueError) as exc:
+            raise DeviceEntityInvalid("expected_version must be an integer") from exc
+        if version < 1:
+            raise DeviceEntityInvalid("expected_version must be positive")
+        return version
+
+    def _candidate(self, uid, *, for_update=False):
+        candidate = self.repository.get_candidate(
+            str(uid),
+            for_update=for_update,
+        )
+        if candidate is None:
+            raise DeviceEntityNotFound(f"candidate {uid} was not found")
+        return candidate
+
+    def _decide(
+        self,
+        candidate,
+        *,
+        decision,
+        canonical_asset_uid,
+        reason,
+        actor_uid,
+    ):
+        if decision not in {"approve", "reject", "auto_approve"}:
+            raise DeviceEntityInvalid("decision is not supported")
+        now = self.now_factory()
+        review = DeviceEntityReviewRecord(
+            uid=self.uid_factory(),
+            candidate_uid=candidate.uid,
+            version=candidate.current_version,
+            decision=decision,
+            reason=reason,
+            actor_uid=actor_uid,
+            created_at=now,
+        )
+        review = self.repository.append_review(review)
+        if decision == "reject":
+            candidate = replace(
+                candidate,
+                status="rejected",
+                current_version=candidate.current_version + 1,
+                reviewed_by=actor_uid,
+                updated_at=now,
+            )
+            return self.repository.update_candidate(candidate), review, None
+
+        pair = {candidate.left_asset_uid, candidate.right_asset_uid}
+        if canonical_asset_uid not in pair:
+            raise DeviceEntityInvalid(
+                "canonical_asset_uid must belong to the candidate pair"
+            )
+        member_uid = next(uid for uid in pair if uid != canonical_asset_uid)
+        if self.repository.active_merge_for_member(canonical_asset_uid):
+            raise DeviceEntityConflict(
+                "canonical asset already belongs to an active merge"
+            )
+        if self.repository.active_merge_for_member(member_uid):
+            raise DeviceEntityConflict(
+                "member asset already belongs to an active merge"
+            )
+        canonical = self.repository.get_asset_detail(canonical_asset_uid)
+        member = self.repository.get_asset_detail(member_uid)
+        self._validate_pair(canonical, member)
+        merge = DeviceEntityMergeRecord(
+            uid=self.uid_factory(),
+            candidate_uid=candidate.uid,
+            canonical_asset_uid=canonical_asset_uid,
+            member_asset_uid=member_uid,
+            review_uid=review.uid,
+            snapshot={
+                "candidate": {
+                    "uid": candidate.uid,
+                    "confidence": candidate.confidence,
+                    "suggestion_source": candidate.suggestion_source,
+                    "evidence_uids": list(candidate.evidence_uids),
+                },
+                "canonical": _asset_snapshot(canonical),
+                "member": _asset_snapshot(member),
+            },
+            actor_uid=actor_uid,
+            created_at=now,
+        )
+        merge = self.repository.create_merge(merge)
+        candidate = replace(
+            candidate,
+            canonical_asset_uid=canonical_asset_uid,
+            status="merged",
+            current_version=candidate.current_version + 1,
+            reviewed_by=actor_uid,
+            updated_at=now,
+        )
+        return self.repository.update_candidate(candidate), review, merge
+
+    def review(self, candidate_uid, payload, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        decision = str(payload.get("decision") or "").strip()
+        reason = _required_text(payload, "reason", maximum=1_000)
+        expected_version = self._version(payload.get("expected_version"))
+        self.review_authorizer(actor_uid)
+        try:
+            candidate = self._candidate(candidate_uid, for_update=True)
+            if candidate.status != "pending":
+                raise DeviceEntityConflict(
+                    "candidate is not pending review"
+                )
+            if candidate.current_version != expected_version:
+                raise DeviceEntityConflict("candidate version conflict")
+            result = self._decide(
+                candidate,
+                decision=decision,
+                canonical_asset_uid=str(
+                    payload.get("canonical_asset_uid") or ""
+                ).strip(),
+                reason=reason,
+                actor_uid=actor_uid,
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback_transaction()
+            raise
+
+    def rollback(self, merge_uid, payload, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        expected_version = self._version(payload.get("expected_version"))
+        reason = _required_text(payload, "reason", maximum=1_000)
+        self.review_authorizer(actor_uid)
+        try:
+            merge = self.repository.get_merge(
+                str(merge_uid),
+                for_update=True,
+            )
+            if merge is None:
+                raise DeviceEntityNotFound(f"merge {merge_uid} was not found")
+            if self.repository.list_rollbacks(merge.uid):
+                raise DeviceEntityConflict("merge was already rolled back")
+            candidate = self._candidate(
+                merge.candidate_uid,
+                for_update=True,
+            )
+            if candidate.status != "merged":
+                raise DeviceEntityConflict("candidate is not actively merged")
+            if candidate.current_version != expected_version:
+                raise DeviceEntityConflict("candidate version conflict")
+            now = self.now_factory()
+            rollback = DeviceEntityRollbackRecord(
+                uid=self.uid_factory(),
+                merge_uid=merge.uid,
+                candidate_uid=candidate.uid,
+                reason=reason,
+                snapshot={
+                    "merge": {
+                        "uid": merge.uid,
+                        "canonical_asset_uid": merge.canonical_asset_uid,
+                        "member_asset_uid": merge.member_asset_uid,
+                        "review_uid": merge.review_uid,
+                        "created_at": (
+                            merge.created_at.isoformat()
+                            if merge.created_at
+                            else None
+                        ),
+                    },
+                    "candidate_version_before": candidate.current_version,
+                },
+                actor_uid=actor_uid,
+                created_at=now,
+            )
+            rollback = self.repository.create_rollback(rollback)
+            candidate = replace(
+                candidate,
+                status="rolled_back",
+                current_version=candidate.current_version + 1,
+                reviewed_by=actor_uid,
+                updated_at=now,
+            )
+            candidate = self.repository.update_candidate(candidate)
+            self.commit()
+            return candidate, rollback
+        except Exception:
+            self.rollback_transaction()
+            raise
+
+    def search(self, filters, *, page=1, page_size=20):
+        if not isinstance(filters, dict):
+            raise DeviceEntityInvalid("filters must be an object")
+        normalized = {}
+        status = str(filters.get("status") or "").strip()
+        if status:
+            if status not in {"pending", "merged", "rejected", "rolled_back"}:
+                raise DeviceEntityInvalid("status is not supported")
+            normalized["status"] = status
+        source = str(filters.get("suggestion_source") or "").strip()
+        if source:
+            if source not in {"rule", "ai", "manual"}:
+                raise DeviceEntityInvalid("suggestion_source is not supported")
+            normalized["suggestion_source"] = source
+        page = _positive_int(
+            {"page": page},
+            "page",
+            default=1,
+            maximum=1_000_000,
+        )
+        page_size = _positive_int(
+            {"page_size": page_size},
+            "page_size",
+            default=20,
+            maximum=MAX_PAGE_SIZE,
+        )
+        return self.repository.search_candidates(
+            normalized,
+            page=page,
+            page_size=page_size,
+        )
+
+    def get(self, candidate_uid):
+        return self._candidate(candidate_uid)
+
+    def reviews(self, candidate_uid):
+        self._candidate(candidate_uid)
+        return self.repository.list_reviews(str(candidate_uid))
+
+    def merges(self, candidate_uid):
+        self._candidate(candidate_uid)
+        return self.repository.list_merges(str(candidate_uid))
+
+    def rollbacks(self, merge_uid):
+        merge = self.repository.get_merge(str(merge_uid))
+        if merge is None:
+            raise DeviceEntityNotFound(f"merge {merge_uid} was not found")
+        return self.repository.list_rollbacks(str(merge_uid))
+
+
+__all__ = [
+    "AUTO_MERGE_THRESHOLD",
+    "DeviceEntityCandidateRecord",
+    "DeviceEntityConflict",
+    "DeviceEntityForbidden",
+    "DeviceEntityGenerationResult",
+    "DeviceEntityInvalid",
+    "DeviceEntityMergeRecord",
+    "DeviceEntityNotFound",
+    "DeviceEntityResolutionService",
+    "DeviceEntityReviewRecord",
+    "DeviceEntityRollbackRecord",
+    "DevicePairScore",
+    "score_device_pair",
+]

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

@@ -78,3 +78,23 @@ class DeviceSemanticNotFound(DataResearchError):
 class DeviceSemanticConflict(DataResearchError):
     code = "DEVICE_SEMANTIC_CONFLICT"
     http_status = 409
+
+
+class DeviceEntityInvalid(DataResearchError):
+    code = "DEVICE_ENTITY_INVALID"
+    http_status = 422
+
+
+class DeviceEntityForbidden(DataResearchError):
+    code = "DEVICE_ENTITY_FORBIDDEN"
+    http_status = 403
+
+
+class DeviceEntityNotFound(DataResearchError):
+    code = "DEVICE_ENTITY_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceEntityConflict(DataResearchError):
+    code = "DEVICE_ENTITY_CONFLICT"
+    http_status = 409

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

@@ -36,6 +36,8 @@ ONTOLOGIES_PUBLISH = "ontologies:publish"
 DEVICE_ASSETS_EDIT = "device-assets:edit"
 DEVICE_SEMANTICS_EDIT = "device-semantics:edit"
 DEVICE_SEMANTICS_REVIEW = "device-semantics:review"
+DEVICE_ENTITIES_EDIT = "device-entities:edit"
+DEVICE_ENTITIES_REVIEW = "device-entities:review"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -56,6 +58,7 @@ ROLE_PERMISSIONS = {
             ONTOLOGIES_EDIT,
             DEVICE_ASSETS_EDIT,
             DEVICE_SEMANTICS_EDIT,
+            DEVICE_ENTITIES_EDIT,
         }
     ),
     "admin": frozenset(
@@ -89,6 +92,8 @@ ROLE_PERMISSIONS = {
             DEVICE_ASSETS_EDIT,
             DEVICE_SEMANTICS_EDIT,
             DEVICE_SEMANTICS_REVIEW,
+            DEVICE_ENTITIES_EDIT,
+            DEVICE_ENTITIES_REVIEW,
         }
     ),
 }
@@ -159,6 +164,12 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path.endswith("/review"):
             return (DEVICE_SEMANTICS_REVIEW,)
         return (DEVICE_SEMANTICS_EDIT,)
+    if path.startswith("/api/development/v1/device-entities"):
+        if method == "GET":
+            return (READ_GOVERNANCE,)
+        if path.endswith("/review") or path.endswith("/rollback"):
+            return (DEVICE_ENTITIES_REVIEW,)
+        return (DEVICE_ENTITIES_EDIT,)
     if path.startswith("/api/development/v1/ingestion-jobs"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 205 - 1
app/models/data_research.py

@@ -8,7 +8,6 @@ from app import db
 from app.core.common.identifiers import new_governance_uid
 from app.core.common.timezone_utils import now_china, now_china_naive
 
-
 JOB_STATUSES = (
     "created",
     "queued",
@@ -514,6 +513,211 @@ class DeviceSemanticCodeReview(db.Model):
     )
 
 
+class DeviceEntityMatchCandidate(db.Model):
+    __tablename__ = "device_entity_match_candidates"
+    __table_args__ = (
+        db.CheckConstraint(
+            "left_asset_uid <> right_asset_uid",
+            name="ck_device_entity_candidate_distinct_assets",
+        ),
+        db.CheckConstraint(
+            "status IN ('pending','merged','rejected','rolled_back')",
+            name="ck_device_entity_candidate_status",
+        ),
+        db.CheckConstraint(
+            "suggestion_source IN ('rule','ai','manual')",
+            name="ck_device_entity_candidate_source",
+        ),
+        db.CheckConstraint(
+            "confidence >= 0 AND confidence <= 1",
+            name="ck_device_entity_candidate_confidence",
+        ),
+        db.CheckConstraint(
+            "current_version > 0",
+            name="ck_device_entity_candidate_version",
+        ),
+        db.Index(
+            "uq_device_entity_open_pair",
+            "left_asset_uid",
+            "right_asset_uid",
+            unique=True,
+            postgresql_where=db.text(
+                "status IN ('pending','merged')"
+            ),
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    left_asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    right_asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    canonical_asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+    )
+    status = db.Column(db.String(20), nullable=False, default="pending")
+    suggestion_source = db.Column(db.String(20), nullable=False)
+    confidence = db.Column(db.Float, nullable=False)
+    explanation = db.Column(JSONB, nullable=False, default=list)
+    evidence_uids = db.Column(JSONB, nullable=False, default=list)
+    model_provider = db.Column(db.String(200))
+    model_name = db.Column(db.String(200))
+    current_version = db.Column(db.Integer, nullable=False, default=1)
+    created_by = db.Column(db.String(100))
+    reviewed_by = db.Column(db.String(100))
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    updated_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceEntityMatchReview(db.Model):
+    __tablename__ = "device_entity_match_reviews"
+    __table_args__ = (
+        db.CheckConstraint(
+            "version > 0",
+            name="ck_device_entity_review_version",
+        ),
+        db.CheckConstraint(
+            "decision IN ('approve','reject','auto_approve')",
+            name="ck_device_entity_review_decision",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    candidate_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_entity_match_candidates.uid",
+            ondelete="CASCADE",
+        ),
+        nullable=False,
+    )
+    version = db.Column(db.Integer, nullable=False)
+    decision = db.Column(db.String(20), nullable=False)
+    reason = db.Column(db.String(1000), nullable=False)
+    actor_uid = db.Column(db.String(100), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceEntityMergeEvent(db.Model):
+    __tablename__ = "device_entity_merge_events"
+    __table_args__ = (
+        db.UniqueConstraint(
+            "candidate_uid",
+            name="uq_device_entity_merge_candidate",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    candidate_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_entity_match_candidates.uid",
+            ondelete="CASCADE",
+        ),
+        nullable=False,
+    )
+    canonical_asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    member_asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    review_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_entity_match_reviews.uid",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    snapshot = db.Column(JSONB, nullable=False)
+    actor_uid = db.Column(db.String(100), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceEntityMergeRollback(db.Model):
+    __tablename__ = "device_entity_merge_rollbacks"
+    __table_args__ = (
+        db.UniqueConstraint(
+            "merge_uid",
+            name="uq_device_entity_merge_rollback",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    merge_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_entity_merge_events.uid",
+            ondelete="CASCADE",
+        ),
+        nullable=False,
+    )
+    candidate_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_entity_match_candidates.uid",
+            ondelete="CASCADE",
+        ),
+        nullable=False,
+    )
+    reason = db.Column(db.String(1000), nullable=False)
+    snapshot = db.Column(JSONB, nullable=False)
+    actor_uid = db.Column(db.String(100), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
 class EvidenceFragment(db.Model):
     __tablename__ = "evidence_fragments"
     __table_args__ = (

+ 329 - 1
deployment/app/api/data_development/routes.py

@@ -12,7 +12,6 @@ from app.api.data_development import bp
 from app.core.data_research.errors import DataResearchError
 from app.models.result import failed, success
 
-
 logger = logging.getLogger(__name__)
 
 
@@ -166,6 +165,53 @@ def get_device_semantic_code_service():
     )
 
 
+def get_device_entity_resolution_service():
+    from app.core.data_research.device_entity_repository import (
+        SqlAlchemyDeviceEntityResolutionRepository,
+    )
+    from app.core.data_research.device_entity_resolution import (
+        DeviceEntityForbidden,
+        DeviceEntityResolutionService,
+    )
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+
+    def assert_accountable(actor_uid):
+        matrix = responsibilities.get(
+            "device_mapping",
+            "DEVICE_ENTITY_RESOLUTION",
+        )
+        accountable = [
+            item
+            for item in matrix.get("assignments", [])
+            if item.get("responsibility_role") == "asset_manager"
+            and item.get("raci_role") == "accountable"
+        ]
+        if len(accountable) != 1 or str(accountable[0].get("user_id")) != str(
+            actor_uid
+        ):
+            raise DeviceEntityForbidden(
+                "only the accountable device mapping asset manager may decide"
+            )
+
+    return DeviceEntityResolutionService(
+        SqlAlchemyDeviceEntityResolutionRepository(db.session),
+        review_authorizer=assert_accountable,
+        auto_merge_enabled=current_app.config.get(
+            "DEVICE_ENTITY_AUTO_MERGE_ENABLED",
+            False,
+        ),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
 def get_candidate_decision_service():
     from app.core.data_research.candidate_decisions import CandidateDecisionService
     from app.core.data_research.data_elements import DataElementService
@@ -536,6 +582,89 @@ def _device_semantic_review(record):
     }
 
 
+def _device_entity_candidate(record):
+    return {
+        "uid": str(record.uid),
+        "left_asset_uid": str(record.left_asset_uid),
+        "right_asset_uid": str(record.right_asset_uid),
+        "canonical_asset_uid": (
+            str(record.canonical_asset_uid)
+            if record.canonical_asset_uid
+            else None
+        ),
+        "status": record.status,
+        "suggestion_source": record.suggestion_source,
+        "confidence": float(record.confidence),
+        "explanation": [
+            dict(item) for item in record.explanation
+        ],
+        "evidence_uids": list(record.evidence_uids),
+        "model_provider": record.model_provider,
+        "model_name": record.model_name,
+        "current_version": int(record.current_version),
+        "created_by": record.created_by,
+        "reviewed_by": record.reviewed_by,
+        "created_at": _iso(record.created_at),
+        "updated_at": _iso(record.updated_at),
+    }
+
+
+def _device_entity_review(record):
+    return {
+        "uid": str(record.uid),
+        "candidate_uid": str(record.candidate_uid),
+        "version": int(record.version),
+        "decision": record.decision,
+        "reason": record.reason,
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_entity_merge(record):
+    return {
+        "uid": str(record.uid),
+        "candidate_uid": str(record.candidate_uid),
+        "canonical_asset_uid": str(record.canonical_asset_uid),
+        "member_asset_uid": str(record.member_asset_uid),
+        "review_uid": str(record.review_uid),
+        "snapshot": dict(record.snapshot),
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_entity_rollback(record):
+    return {
+        "uid": str(record.uid),
+        "merge_uid": str(record.merge_uid),
+        "candidate_uid": str(record.candidate_uid),
+        "reason": record.reason,
+        "snapshot": dict(record.snapshot),
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_entity_generation(result):
+    return {
+        "records": [
+            _device_entity_candidate(record)
+            for record in result.records
+        ],
+        "created_count": int(result.created_count),
+        "existing_count": int(result.existing_count),
+        "evaluated_pair_count": int(result.evaluated_pair_count),
+        "auto_merged_count": int(result.auto_merged_count),
+        "auto_merge_enabled": bool(
+            current_app.config.get(
+                "DEVICE_ENTITY_AUTO_MERGE_ENABLED",
+                False,
+            )
+        ),
+    }
+
+
 def _device_asset_page(name, *, default, maximum):
     from app.core.data_research.errors import DeviceAssetInvalid
 
@@ -965,6 +1094,205 @@ def list_device_semantic_code_reviews(code_uid):
         return _error(error)
 
 
+@bp.route("/device-entities/candidates", methods=["GET"])
+def list_device_entity_candidates():
+    filters = {
+        name: request.args.get(name)
+        for name in ("status", "suggestion_source")
+        if request.args.get(name)
+    }
+    try:
+        records, total = get_device_entity_resolution_service().search(
+            filters,
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_candidate(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(request.args.get("page", 1)),
+                    "page_size": int(request.args.get("page_size", 20)),
+                    "auto_merge_enabled": bool(
+                        current_app.config.get(
+                            "DEVICE_ENTITY_AUTO_MERGE_ENABLED",
+                            False,
+                        )
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-entities/candidates/generate", methods=["POST"])
+def generate_device_entity_candidates():
+    try:
+        result = get_device_entity_resolution_service().generate(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        status = 201 if result.created_count else 200
+        return jsonify(success(_device_entity_generation(result))), status
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-entities/candidates", methods=["POST"])
+def submit_device_entity_candidate():
+    try:
+        record = (
+            get_device_entity_resolution_service().submit_ai_candidate(
+                request.get_json(silent=True) or {},
+                actor_uid=_identity().get("id") or _identity().get("sub"),
+            )
+        )
+        return jsonify(success(_device_entity_candidate(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-entities/candidates/<candidate_uid>", methods=["GET"])
+def get_device_entity_candidate(candidate_uid):
+    try:
+        record = get_device_entity_resolution_service().get(candidate_uid)
+        return jsonify(success(_device_entity_candidate(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/candidates/<candidate_uid>/review",
+    methods=["POST"],
+)
+def review_device_entity_candidate(candidate_uid):
+    try:
+        candidate, review, merge = (
+            get_device_entity_resolution_service().review(
+                candidate_uid,
+                request.get_json(silent=True) or {},
+                actor_uid=_identity().get("id") or _identity().get("sub"),
+            )
+        )
+        return jsonify(
+            success(
+                {
+                    "candidate": _device_entity_candidate(candidate),
+                    "review": _device_entity_review(review),
+                    "merge": (
+                        _device_entity_merge(merge)
+                        if merge is not None
+                        else None
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/candidates/<candidate_uid>/reviews",
+    methods=["GET"],
+)
+def list_device_entity_reviews(candidate_uid):
+    try:
+        records = get_device_entity_resolution_service().reviews(
+            candidate_uid
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_review(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/candidates/<candidate_uid>/merges",
+    methods=["GET"],
+)
+def list_device_entity_merges(candidate_uid):
+    try:
+        records = get_device_entity_resolution_service().merges(
+            candidate_uid
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_merge(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/merges/<merge_uid>/rollback",
+    methods=["POST"],
+)
+def rollback_device_entity_merge(merge_uid):
+    try:
+        candidate, rollback = (
+            get_device_entity_resolution_service().rollback(
+                merge_uid,
+                request.get_json(silent=True) or {},
+                actor_uid=_identity().get("id") or _identity().get("sub"),
+            )
+        )
+        return jsonify(
+            success(
+                {
+                    "candidate": _device_entity_candidate(candidate),
+                    "rollback": _device_entity_rollback(rollback),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/merges/<merge_uid>/rollbacks",
+    methods=["GET"],
+)
+def list_device_entity_rollbacks(merge_uid):
+    try:
+        records = get_device_entity_resolution_service().rollbacks(
+            merge_uid
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_rollback(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

+ 53 - 4
deployment/app/config/config.py

@@ -1,6 +1,6 @@
 import os
 import platform
-from typing import Mapping
+from collections.abc import Mapping
 
 
 def get_bool_env(name: str, default: bool = False) -> bool:
@@ -144,9 +144,7 @@ def is_placeholder_env_value(value: str) -> bool:
         return True
     if "dataops_user@" in lower and "127.0.0.1" in lower:
         return True
-    if lower in {"127.0.0.1:9000", "localhost:9000"}:
-        return True
-    return False
+    return lower in {"127.0.0.1:9000", "localhost:9000"}
 
 
 def _is_local_minio_host(host: str) -> bool:
@@ -364,6 +362,9 @@ class BaseConfig:
     """基础配置类,包含所有环境共享的配置"""
 
     SECRET_KEY = os.environ.get("SECRET_KEY") or "you-will-never-guess"
+    RULE_GENERATION_RECEIPT_SECRET = os.environ.get(
+        "RULE_GENERATION_RECEIPT_SECRET"
+    )
     JSON_AS_ASCII = False
     JSONIFY_PRETTYPRINT_REGULAR = True
     JSON_SORT_KEYS = False
@@ -435,6 +436,33 @@ class BaseConfig:
     # 兼容旧环境变量名 LLM_API_KEY
     LLM_API_KEY = DEEPSEEK_API_KEY or os.environ.get("LLM_API_KEY", "")
 
+    # 数据治理知识库
+    KNOWLEDGE_ENABLED = os.environ.get("KNOWLEDGE_ENABLED", "true").lower() == "true"
+    KNOWLEDGE_VECTOR_TOP_K = int(os.environ.get("KNOWLEDGE_VECTOR_TOP_K", "40"))
+    KNOWLEDGE_RRF_K = int(os.environ.get("KNOWLEDGE_RRF_K", "60"))
+    KNOWLEDGE_RERANK_TOP_K = int(os.environ.get("KNOWLEDGE_RERANK_TOP_K", "8"))
+    KNOWLEDGE_EVIDENCE_TOKEN_BUDGET = int(
+        os.environ.get("KNOWLEDGE_EVIDENCE_TOKEN_BUDGET", "8000")
+    )
+    KNOWLEDGE_IMPACT_MAX_HOPS = int(os.environ.get("KNOWLEDGE_IMPACT_MAX_HOPS", "3"))
+    KNOWLEDGE_IMPACT_MAX_POINTS = int(
+        os.environ.get("KNOWLEDGE_IMPACT_MAX_POINTS", "1000")
+    )
+    KNOWLEDGE_LIGHTRAG_ENABLED = (
+        os.environ.get("KNOWLEDGE_LIGHTRAG_ENABLED", "false").lower() == "true"
+    )
+    KNOWLEDGE_LIGHTRAG_SHADOW_ONLY = (
+        os.environ.get("KNOWLEDGE_LIGHTRAG_SHADOW_ONLY", "true").lower() == "true"
+    )
+    KNOWLEDGE_LIGHTRAG_BASE_URL = os.environ.get(
+        "KNOWLEDGE_LIGHTRAG_BASE_URL", "http://lightrag:9621"
+    )
+    KNOWLEDGE_LIGHTRAG_API_KEY = os.environ.get("KNOWLEDGE_LIGHTRAG_API_KEY", "")
+    QWEN_EMBEDDING_BASE_URL = os.environ.get("QWEN_EMBEDDING_BASE_URL", "")
+    QWEN_EMBEDDING_API_KEY = os.environ.get("QWEN_EMBEDDING_API_KEY", "")
+    QWEN_EMBEDDING_MODEL = os.environ.get("QWEN_EMBEDDING_MODEL", "text-embedding-v3")
+    QWEN_EMBEDDING_DIMENSION = int(os.environ.get("QWEN_EMBEDDING_DIMENSION", "1024"))
+
     # 日志基础配置
     LOG_FORMAT = "%(asctime)s - %(levelname)s - %(filename)s - %(funcName)s - %(lineno)s - %(message)s"
     LOG_ENCODING = "UTF-8"
@@ -448,6 +476,27 @@ class BaseConfig:
     N8N_API_KEY = os.environ.get("N8N_API_KEY", "")
     N8N_API_TIMEOUT = int(os.environ.get("N8N_API_TIMEOUT", "30"))
 
+    # Kestra-backed governed Data Factory. Activation remains fail-closed
+    # unless the operator explicitly opens the post-acceptance gate.
+    KESTRA_BASE_URL = os.environ.get(
+        "KESTRA_BASE_URL", "http://127.0.0.1:18080/api/v1"
+    )
+    KESTRA_TENANT_ID = os.environ.get("KESTRA_TENANT_ID", "main")
+    KESTRA_USERNAME = os.environ.get("KESTRA_USERNAME", "")
+    KESTRA_PASSWORD = os.environ.get("KESTRA_PASSWORD", "")
+    KESTRA_HTTP_TIMEOUT_SECONDS = int(
+        os.environ.get("KESTRA_HTTP_TIMEOUT_SECONDS", "30")
+    )
+    RUNNER_TASK_TOKEN_SECRET = os.environ.get(
+        "RUNNER_TASK_TOKEN_SECRET", ""
+    )
+    DATA_FACTORY_ACTIVATION_ENABLED = get_bool_env(
+        "DATA_FACTORY_ACTIVATION_ENABLED", False
+    )
+    DEVICE_ENTITY_AUTO_MERGE_ENABLED = get_bool_env(
+        "DEVICE_ENTITY_AUTO_MERGE_ENABLED", False
+    )
+
     # DataOps 平台 API 基础 URL(用于 n8n 工作流回调等)
     API_BASE_URL = os.environ.get("API_BASE_URL", "http://127.0.0.1:5500/api")
 

+ 295 - 0
deployment/app/core/data_research/device_entity_repository.py

@@ -0,0 +1,295 @@
+"""SQLAlchemy persistence for governed device entity resolution."""
+
+from __future__ import annotations
+
+from sqlalchemy import func
+
+from app.core.data_research.device_asset_repository import (
+    SqlAlchemyDeviceAssetRepository,
+)
+from app.core.data_research.device_entity_resolution import (
+    DeviceEntityCandidateRecord,
+    DeviceEntityMergeRecord,
+    DeviceEntityReviewRecord,
+    DeviceEntityRollbackRecord,
+)
+from app.models.data_research import (
+    DeviceAsset,
+    DeviceEntityMatchCandidate,
+    DeviceEntityMatchReview,
+    DeviceEntityMergeEvent,
+    DeviceEntityMergeRollback,
+)
+
+
+class SqlAlchemyDeviceEntityResolutionRepository:
+    def __init__(self, session):
+        self.session = session
+        self.assets = SqlAlchemyDeviceAssetRepository(session)
+
+    @staticmethod
+    def _candidate(model):
+        return DeviceEntityCandidateRecord(
+            uid=str(model.uid),
+            left_asset_uid=str(model.left_asset_uid),
+            right_asset_uid=str(model.right_asset_uid),
+            canonical_asset_uid=(
+                str(model.canonical_asset_uid)
+                if model.canonical_asset_uid
+                else None
+            ),
+            status=model.status,
+            suggestion_source=model.suggestion_source,
+            confidence=float(model.confidence),
+            explanation=tuple(
+                dict(item) for item in (model.explanation or [])
+            ),
+            evidence_uids=tuple(model.evidence_uids or []),
+            model_provider=model.model_provider,
+            model_name=model.model_name,
+            current_version=int(model.current_version),
+            created_by=model.created_by,
+            reviewed_by=model.reviewed_by,
+            created_at=model.created_at,
+            updated_at=model.updated_at,
+        )
+
+    @staticmethod
+    def _review(model):
+        return DeviceEntityReviewRecord(
+            uid=str(model.uid),
+            candidate_uid=str(model.candidate_uid),
+            version=int(model.version),
+            decision=model.decision,
+            reason=model.reason,
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _merge(model):
+        return DeviceEntityMergeRecord(
+            uid=str(model.uid),
+            candidate_uid=str(model.candidate_uid),
+            canonical_asset_uid=str(model.canonical_asset_uid),
+            member_asset_uid=str(model.member_asset_uid),
+            review_uid=str(model.review_uid),
+            snapshot=dict(model.snapshot or {}),
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _rollback(model):
+        return DeviceEntityRollbackRecord(
+            uid=str(model.uid),
+            merge_uid=str(model.merge_uid),
+            candidate_uid=str(model.candidate_uid),
+            reason=model.reason,
+            snapshot=dict(model.snapshot or {}),
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    def get_asset_detail(self, uid):
+        asset = self.assets.get(str(uid))
+        if asset is None:
+            return None
+        from app.core.data_research.device_assets import DeviceAssetDetail
+
+        return DeviceAssetDetail(
+            asset=asset,
+            mappings=tuple(self.assets.list_mappings(asset.uid)),
+        )
+
+    def list_matchable_assets(self, asset_type, *, limit):
+        rows = (
+            self.session.query(DeviceAsset)
+            .filter_by(asset_type=str(asset_type), status="active")
+            .order_by(DeviceAsset.uid.asc())
+            .limit(int(limit))
+            .all()
+        )
+        return [
+            self.get_asset_detail(str(model.uid))
+            for model in rows
+        ]
+
+    def find_open_pair(self, left_uid, right_uid):
+        model = (
+            self.session.query(DeviceEntityMatchCandidate)
+            .filter_by(
+                left_asset_uid=str(left_uid),
+                right_asset_uid=str(right_uid),
+            )
+            .filter(
+                DeviceEntityMatchCandidate.status.in_(("pending", "merged"))
+            )
+            .first()
+        )
+        return self._candidate(model) if model is not None else None
+
+    @staticmethod
+    def _candidate_model(record):
+        return DeviceEntityMatchCandidate(
+            uid=record.uid,
+            left_asset_uid=record.left_asset_uid,
+            right_asset_uid=record.right_asset_uid,
+            canonical_asset_uid=record.canonical_asset_uid,
+            status=record.status,
+            suggestion_source=record.suggestion_source,
+            confidence=record.confidence,
+            explanation=list(record.explanation),
+            evidence_uids=list(record.evidence_uids),
+            model_provider=record.model_provider,
+            model_name=record.model_name,
+            current_version=record.current_version,
+            created_by=record.created_by,
+            reviewed_by=record.reviewed_by,
+            created_at=record.created_at,
+            updated_at=record.updated_at,
+        )
+
+    def create_candidate(self, record):
+        model = self._candidate_model(record)
+        self.session.add(model)
+        self.session.flush()
+        return self._candidate(model)
+
+    def search_candidates(self, filters, *, page, page_size):
+        query = self.session.query(DeviceEntityMatchCandidate)
+        if filters.get("status"):
+            query = query.filter_by(status=str(filters["status"]))
+        if filters.get("suggestion_source"):
+            query = query.filter_by(
+                suggestion_source=str(filters["suggestion_source"])
+            )
+        total = query.with_entities(func.count()).scalar() or 0
+        rows = (
+            query.order_by(
+                DeviceEntityMatchCandidate.updated_at.desc(),
+                DeviceEntityMatchCandidate.uid.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._candidate(model) for model in rows], int(total)
+
+    def get_candidate(self, uid, *, for_update=False):
+        query = self.session.query(DeviceEntityMatchCandidate).filter_by(
+            uid=str(uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._candidate(model) if model is not None else None
+
+    def update_candidate(self, record):
+        model = self.session.get(DeviceEntityMatchCandidate, str(record.uid))
+        for name in (
+            "canonical_asset_uid",
+            "status",
+            "current_version",
+            "reviewed_by",
+            "updated_at",
+        ):
+            setattr(model, name, getattr(record, name))
+        self.session.flush()
+        return self._candidate(model)
+
+    def append_review(self, record):
+        model = DeviceEntityMatchReview(
+            uid=record.uid,
+            candidate_uid=record.candidate_uid,
+            version=record.version,
+            decision=record.decision,
+            reason=record.reason,
+            actor_uid=record.actor_uid,
+            created_at=record.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._review(model)
+
+    def active_merge_for_member(self, asset_uid):
+        model = (
+            self.session.query(DeviceEntityMergeEvent)
+            .outerjoin(
+                DeviceEntityMergeRollback,
+                DeviceEntityMergeRollback.merge_uid
+                == DeviceEntityMergeEvent.uid,
+            )
+            .filter(
+                DeviceEntityMergeEvent.member_asset_uid == str(asset_uid),
+                DeviceEntityMergeRollback.uid.is_(None),
+            )
+            .order_by(DeviceEntityMergeEvent.created_at.desc())
+            .first()
+        )
+        return self._merge(model) if model is not None else None
+
+    def create_merge(self, record):
+        model = DeviceEntityMergeEvent(
+            uid=record.uid,
+            candidate_uid=record.candidate_uid,
+            canonical_asset_uid=record.canonical_asset_uid,
+            member_asset_uid=record.member_asset_uid,
+            review_uid=record.review_uid,
+            snapshot=record.snapshot,
+            actor_uid=record.actor_uid,
+            created_at=record.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._merge(model)
+
+    def get_merge(self, uid, *, for_update=False):
+        query = self.session.query(DeviceEntityMergeEvent).filter_by(
+            uid=str(uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._merge(model) if model is not None else None
+
+    def create_rollback(self, record):
+        model = DeviceEntityMergeRollback(
+            uid=record.uid,
+            merge_uid=record.merge_uid,
+            candidate_uid=record.candidate_uid,
+            reason=record.reason,
+            snapshot=record.snapshot,
+            actor_uid=record.actor_uid,
+            created_at=record.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._rollback(model)
+
+    def list_reviews(self, candidate_uid):
+        return [
+            self._review(model)
+            for model in self.session.query(DeviceEntityMatchReview)
+            .filter_by(candidate_uid=str(candidate_uid))
+            .order_by(DeviceEntityMatchReview.created_at.desc())
+            .all()
+        ]
+
+    def list_merges(self, candidate_uid):
+        return [
+            self._merge(model)
+            for model in self.session.query(DeviceEntityMergeEvent)
+            .filter_by(candidate_uid=str(candidate_uid))
+            .order_by(DeviceEntityMergeEvent.created_at.desc())
+            .all()
+        ]
+
+    def list_rollbacks(self, merge_uid):
+        return [
+            self._rollback(model)
+            for model in self.session.query(DeviceEntityMergeRollback)
+            .filter_by(merge_uid=str(merge_uid))
+            .order_by(DeviceEntityMergeRollback.created_at.desc())
+            .all()
+        ]

+ 737 - 0
deployment/app/core/data_research/device_entity_resolution.py

@@ -0,0 +1,737 @@
+"""Governed, explainable, and reversible cross-source device resolution."""
+
+from __future__ import annotations
+
+import unicodedata
+from collections.abc import Callable
+from dataclasses import dataclass, replace
+from datetime import datetime
+from itertools import combinations
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+from app.core.data_research.device_assets import ASSET_TYPES, DeviceAssetDetail
+from app.core.data_research.errors import (
+    DeviceEntityConflict,
+    DeviceEntityForbidden,
+    DeviceEntityInvalid,
+    DeviceEntityNotFound,
+)
+
+MAX_MATCH_ASSETS = 500
+MAX_NEW_CANDIDATES = 2_000
+MAX_PAGE_SIZE = 100
+AUTO_MERGE_THRESHOLD = 0.98
+SIGNAL_WEIGHTS = (
+    ("name", 0.50),
+    ("location", 0.15),
+    ("organization", 0.10),
+    ("responsible_person", 0.05),
+    ("model", 0.15),
+    ("source_code", 0.05),
+)
+SECRET_KEYS = frozenset(
+    {
+        "password",
+        "passwd",
+        "credential",
+        "credentials",
+        "token",
+        "api_key",
+        "authorization",
+        "connection_string",
+        "connection_url",
+        "phone",
+        "mobile",
+        "email",
+        "contact_phone",
+        "contact_email",
+        "raw_measurements",
+        "measurements",
+    }
+)
+
+
+@dataclass(frozen=True)
+class DevicePairScore:
+    confidence: float
+    explanation: tuple[dict[str, Any], ...]
+
+
+@dataclass(frozen=True)
+class DeviceEntityCandidateRecord:
+    uid: str
+    left_asset_uid: str
+    right_asset_uid: str
+    canonical_asset_uid: str | None
+    status: str
+    suggestion_source: str
+    confidence: float
+    explanation: tuple[dict[str, Any], ...]
+    evidence_uids: tuple[str, ...]
+    model_provider: str | None
+    model_name: str | None
+    current_version: int
+    created_by: str | None
+    reviewed_by: str | None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityReviewRecord:
+    uid: str
+    candidate_uid: str
+    version: int
+    decision: str
+    reason: str
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityMergeRecord:
+    uid: str
+    candidate_uid: str
+    canonical_asset_uid: str
+    member_asset_uid: str
+    review_uid: str
+    snapshot: dict[str, Any]
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityRollbackRecord:
+    uid: str
+    merge_uid: str
+    candidate_uid: str
+    reason: str
+    snapshot: dict[str, Any]
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityGenerationResult:
+    records: tuple[DeviceEntityCandidateRecord, ...]
+    created_count: int
+    existing_count: int
+    evaluated_pair_count: int
+    auto_merged_count: int
+
+
+def _normalized(value: Any) -> str:
+    text = unicodedata.normalize("NFKC", str(value or "")).casefold()
+    return "".join(character for character in text if character.isalnum())
+
+
+def _mapping_value(detail: DeviceAssetDetail, name: str) -> str:
+    if name == "source_code":
+        values = sorted(
+            {
+                _normalized(mapping.source_code)
+                for mapping in detail.mappings
+                if _normalized(mapping.source_code)
+            }
+        )
+        return "|".join(values)
+    return ""
+
+
+def _signal_value(detail: DeviceAssetDetail, name: str) -> str:
+    if name == "model":
+        return _normalized((detail.asset.attributes or {}).get("model"))
+    if name == "source_code":
+        return _mapping_value(detail, name)
+    return _normalized(getattr(detail.asset, name, None))
+
+
+def score_device_pair(
+    left: DeviceAssetDetail,
+    right: DeviceAssetDetail,
+) -> DevicePairScore:
+    explanation = []
+    confidence = 0.0
+    for signal, weight in SIGNAL_WEIGHTS:
+        left_value = _signal_value(left, signal)
+        right_value = _signal_value(right, signal)
+        matched = bool(left_value and right_value and left_value == right_value)
+        if matched:
+            confidence += weight
+        explanation.append(
+            {
+                "signal": signal,
+                "matched": matched,
+                "weight": weight,
+                "left": left_value or None,
+                "right": right_value or None,
+            }
+        )
+    return DevicePairScore(
+        confidence=round(confidence, 6),
+        explanation=tuple(explanation),
+    )
+
+
+def _required_text(
+    payload: dict[str, Any],
+    name: str,
+    *,
+    maximum: int = 1_000,
+) -> str:
+    value = str(payload.get(name) or "").strip()
+    if not value:
+        raise DeviceEntityInvalid(f"{name} is required")
+    if len(value) > maximum:
+        raise DeviceEntityInvalid(f"{name} exceeds {maximum} characters")
+    return value
+
+
+def _bounded_float(payload, name, *, default=None):
+    raw = payload.get(name, default)
+    try:
+        value = float(raw)
+    except (TypeError, ValueError) as exc:
+        raise DeviceEntityInvalid(f"{name} must be a number") from exc
+    if value < 0 or value > 1:
+        raise DeviceEntityInvalid(f"{name} must be between 0 and 1")
+    return value
+
+
+def _positive_int(payload, name, *, default, maximum):
+    raw = payload.get(name, default)
+    try:
+        value = int(raw)
+    except (TypeError, ValueError) as exc:
+        raise DeviceEntityInvalid(f"{name} must be an integer") from exc
+    if value < 1 or value > maximum:
+        raise DeviceEntityInvalid(
+            f"{name} must be between 1 and {maximum}"
+        )
+    return value
+
+
+def _reject_sensitive(value: Any) -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if str(key).strip().lower() in SECRET_KEYS:
+                raise DeviceEntityInvalid(
+                    "sensitive fields are not allowed in entity resolution"
+                )
+            _reject_sensitive(item)
+    elif isinstance(value, (list, tuple)):
+        for item in value:
+            _reject_sensitive(item)
+
+
+def _evidence_uids(payload, *, required=True) -> tuple[str, ...]:
+    values = payload.get("evidence_uids")
+    if not isinstance(values, list):
+        raise DeviceEntityInvalid("evidence_uids must be an array")
+    records = tuple(
+        dict.fromkeys(
+            str(value).strip()
+            for value in values
+            if str(value).strip()
+        )
+    )
+    if required and not records:
+        raise DeviceEntityInvalid("evidence_uids are required")
+    if len(records) > 100 or any(len(item) > 300 for item in records):
+        raise DeviceEntityInvalid("evidence_uids exceed the governed boundary")
+    return records
+
+
+def _pair(left_uid: str, right_uid: str) -> tuple[str, str]:
+    left_uid = str(left_uid).strip()
+    right_uid = str(right_uid).strip()
+    if not left_uid or not right_uid or left_uid == right_uid:
+        raise DeviceEntityInvalid("candidate requires two distinct assets")
+    return tuple(sorted((left_uid, right_uid)))
+
+
+def _source_uids(detail: DeviceAssetDetail) -> set[str]:
+    return {str(mapping.source_uid) for mapping in detail.mappings}
+
+
+def _asset_snapshot(detail: DeviceAssetDetail) -> dict[str, Any]:
+    return {
+        "uid": detail.asset.uid,
+        "asset_type": detail.asset.asset_type,
+        "name": detail.asset.name,
+        "location": detail.asset.location,
+        "organization": detail.asset.organization,
+        "responsible_person": detail.asset.responsible_person,
+        "model": (detail.asset.attributes or {}).get("model"),
+        "source_mappings": [
+            {
+                "uid": mapping.uid,
+                "source_uid": mapping.source_uid,
+                "source_entity": mapping.source_entity,
+                "source_code": mapping.source_code,
+            }
+            for mapping in detail.mappings
+        ],
+    }
+
+
+class DeviceEntityResolutionService:
+    def __init__(
+        self,
+        repository,
+        *,
+        review_authorizer: Callable[[str], Any] = lambda _actor_uid: None,
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        auto_merge_enabled: bool = False,
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self.repository = repository
+        self.review_authorizer = review_authorizer
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.auto_merge_enabled = bool(auto_merge_enabled)
+        self.commit = commit
+        self.rollback_transaction = rollback
+
+    @staticmethod
+    def _validate_pair(left, right):
+        if left is None or right is None:
+            raise DeviceEntityNotFound("candidate asset was not found")
+        if left.asset.status != "active" or right.asset.status != "active":
+            raise DeviceEntityInvalid("candidate assets must be active")
+        if left.asset.asset_type != right.asset.asset_type:
+            raise DeviceEntityInvalid("candidate assets must have the same type")
+        if _source_uids(left) & _source_uids(right):
+            raise DeviceEntityInvalid(
+                "candidate assets must come from different source systems"
+            )
+
+    @staticmethod
+    def _rule_evidence(left, right):
+        return tuple(
+            dict.fromkeys(
+                (
+                    left.asset.uid,
+                    *(mapping.uid for mapping in left.mappings),
+                    right.asset.uid,
+                    *(mapping.uid for mapping in right.mappings),
+                )
+            )
+        )
+
+    def generate(self, payload, actor_uid) -> DeviceEntityGenerationResult:
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        asset_type = str(payload.get("asset_type") or "device").strip()
+        if asset_type not in ASSET_TYPES:
+            raise DeviceEntityInvalid("asset_type is not supported")
+        threshold = _bounded_float(payload, "threshold", default=0.7)
+        limit = _positive_int(
+            payload,
+            "limit",
+            default=MAX_MATCH_ASSETS,
+            maximum=MAX_MATCH_ASSETS,
+        )
+        details = self.repository.list_matchable_assets(
+            asset_type,
+            limit=limit,
+        )
+        records = []
+        created_count = 0
+        existing_count = 0
+        evaluated = 0
+        auto_merged_count = 0
+        try:
+            for first, second in combinations(details, 2):
+                if len(records) >= MAX_NEW_CANDIDATES:
+                    break
+                left_uid, right_uid = _pair(
+                    first.asset.uid,
+                    second.asset.uid,
+                )
+                left = first if first.asset.uid == left_uid else second
+                right = second if second.asset.uid == right_uid else first
+                try:
+                    self._validate_pair(left, right)
+                except DeviceEntityInvalid:
+                    continue
+                evaluated += 1
+                score = score_device_pair(left, right)
+                if score.confidence < threshold:
+                    continue
+                existing = self.repository.find_open_pair(
+                    left_uid,
+                    right_uid,
+                )
+                if existing is not None:
+                    records.append(existing)
+                    existing_count += 1
+                    continue
+                now = self.now_factory()
+                candidate = DeviceEntityCandidateRecord(
+                    uid=self.uid_factory(),
+                    left_asset_uid=left_uid,
+                    right_asset_uid=right_uid,
+                    canonical_asset_uid=None,
+                    status="pending",
+                    suggestion_source="rule",
+                    confidence=score.confidence,
+                    explanation=score.explanation,
+                    evidence_uids=self._rule_evidence(left, right),
+                    model_provider=None,
+                    model_name=None,
+                    current_version=1,
+                    created_by=actor_uid,
+                    reviewed_by=None,
+                    created_at=now,
+                    updated_at=now,
+                )
+                candidate = self.repository.create_candidate(candidate)
+                created_count += 1
+                if (
+                    self.auto_merge_enabled
+                    and candidate.confidence >= AUTO_MERGE_THRESHOLD
+                ):
+                    self.review_authorizer(actor_uid)
+                    candidate, _review, _merge = self._decide(
+                        candidate,
+                        decision="auto_approve",
+                        canonical_asset_uid=left_uid,
+                        reason="strict deterministic auto-merge threshold met",
+                        actor_uid=actor_uid,
+                    )
+                    auto_merged_count += 1
+                records.append(candidate)
+            self.commit()
+        except Exception:
+            self.rollback_transaction()
+            raise
+        return DeviceEntityGenerationResult(
+            records=tuple(records),
+            created_count=created_count,
+            existing_count=existing_count,
+            evaluated_pair_count=evaluated,
+            auto_merged_count=auto_merged_count,
+        )
+
+    def submit_ai_candidate(self, payload, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        _reject_sensitive(payload)
+        left_uid, right_uid = _pair(
+            payload.get("left_asset_uid"),
+            payload.get("right_asset_uid"),
+        )
+        left = self.repository.get_asset_detail(left_uid)
+        right = self.repository.get_asset_detail(right_uid)
+        self._validate_pair(left, right)
+        if self.repository.active_merge_for_member(left_uid) or (
+            self.repository.active_merge_for_member(right_uid)
+        ):
+            raise DeviceEntityConflict(
+                "candidate asset already belongs to an active merge"
+            )
+        if self.repository.find_open_pair(left_uid, right_uid):
+            raise DeviceEntityConflict("candidate pair already has an active merge")
+        confidence = _bounded_float(payload, "confidence")
+        provider = _required_text(payload, "model_provider", maximum=200)
+        model_name = _required_text(payload, "model_name", maximum=200)
+        explanation_text = _required_text(
+            payload,
+            "explanation",
+            maximum=2_000,
+        )
+        evidence = _evidence_uids(payload)
+        now = self.now_factory()
+        candidate = DeviceEntityCandidateRecord(
+            uid=self.uid_factory(),
+            left_asset_uid=left_uid,
+            right_asset_uid=right_uid,
+            canonical_asset_uid=None,
+            status="pending",
+            suggestion_source="ai",
+            confidence=confidence,
+            explanation=(
+                {
+                    "signal": "ai_explanation",
+                    "matched": True,
+                    "weight": confidence,
+                    "left": explanation_text,
+                    "right": None,
+                },
+            ),
+            evidence_uids=evidence,
+            model_provider=provider,
+            model_name=model_name,
+            current_version=1,
+            created_by=actor_uid,
+            reviewed_by=None,
+            created_at=now,
+            updated_at=now,
+        )
+        try:
+            candidate = self.repository.create_candidate(candidate)
+            self.commit()
+            return candidate
+        except Exception:
+            self.rollback_transaction()
+            raise
+
+    @staticmethod
+    def _version(value):
+        try:
+            version = int(value)
+        except (TypeError, ValueError) as exc:
+            raise DeviceEntityInvalid("expected_version must be an integer") from exc
+        if version < 1:
+            raise DeviceEntityInvalid("expected_version must be positive")
+        return version
+
+    def _candidate(self, uid, *, for_update=False):
+        candidate = self.repository.get_candidate(
+            str(uid),
+            for_update=for_update,
+        )
+        if candidate is None:
+            raise DeviceEntityNotFound(f"candidate {uid} was not found")
+        return candidate
+
+    def _decide(
+        self,
+        candidate,
+        *,
+        decision,
+        canonical_asset_uid,
+        reason,
+        actor_uid,
+    ):
+        if decision not in {"approve", "reject", "auto_approve"}:
+            raise DeviceEntityInvalid("decision is not supported")
+        now = self.now_factory()
+        review = DeviceEntityReviewRecord(
+            uid=self.uid_factory(),
+            candidate_uid=candidate.uid,
+            version=candidate.current_version,
+            decision=decision,
+            reason=reason,
+            actor_uid=actor_uid,
+            created_at=now,
+        )
+        review = self.repository.append_review(review)
+        if decision == "reject":
+            candidate = replace(
+                candidate,
+                status="rejected",
+                current_version=candidate.current_version + 1,
+                reviewed_by=actor_uid,
+                updated_at=now,
+            )
+            return self.repository.update_candidate(candidate), review, None
+
+        pair = {candidate.left_asset_uid, candidate.right_asset_uid}
+        if canonical_asset_uid not in pair:
+            raise DeviceEntityInvalid(
+                "canonical_asset_uid must belong to the candidate pair"
+            )
+        member_uid = next(uid for uid in pair if uid != canonical_asset_uid)
+        if self.repository.active_merge_for_member(canonical_asset_uid):
+            raise DeviceEntityConflict(
+                "canonical asset already belongs to an active merge"
+            )
+        if self.repository.active_merge_for_member(member_uid):
+            raise DeviceEntityConflict(
+                "member asset already belongs to an active merge"
+            )
+        canonical = self.repository.get_asset_detail(canonical_asset_uid)
+        member = self.repository.get_asset_detail(member_uid)
+        self._validate_pair(canonical, member)
+        merge = DeviceEntityMergeRecord(
+            uid=self.uid_factory(),
+            candidate_uid=candidate.uid,
+            canonical_asset_uid=canonical_asset_uid,
+            member_asset_uid=member_uid,
+            review_uid=review.uid,
+            snapshot={
+                "candidate": {
+                    "uid": candidate.uid,
+                    "confidence": candidate.confidence,
+                    "suggestion_source": candidate.suggestion_source,
+                    "evidence_uids": list(candidate.evidence_uids),
+                },
+                "canonical": _asset_snapshot(canonical),
+                "member": _asset_snapshot(member),
+            },
+            actor_uid=actor_uid,
+            created_at=now,
+        )
+        merge = self.repository.create_merge(merge)
+        candidate = replace(
+            candidate,
+            canonical_asset_uid=canonical_asset_uid,
+            status="merged",
+            current_version=candidate.current_version + 1,
+            reviewed_by=actor_uid,
+            updated_at=now,
+        )
+        return self.repository.update_candidate(candidate), review, merge
+
+    def review(self, candidate_uid, payload, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        decision = str(payload.get("decision") or "").strip()
+        reason = _required_text(payload, "reason", maximum=1_000)
+        expected_version = self._version(payload.get("expected_version"))
+        self.review_authorizer(actor_uid)
+        try:
+            candidate = self._candidate(candidate_uid, for_update=True)
+            if candidate.status != "pending":
+                raise DeviceEntityConflict(
+                    "candidate is not pending review"
+                )
+            if candidate.current_version != expected_version:
+                raise DeviceEntityConflict("candidate version conflict")
+            result = self._decide(
+                candidate,
+                decision=decision,
+                canonical_asset_uid=str(
+                    payload.get("canonical_asset_uid") or ""
+                ).strip(),
+                reason=reason,
+                actor_uid=actor_uid,
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback_transaction()
+            raise
+
+    def rollback(self, merge_uid, payload, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        expected_version = self._version(payload.get("expected_version"))
+        reason = _required_text(payload, "reason", maximum=1_000)
+        self.review_authorizer(actor_uid)
+        try:
+            merge = self.repository.get_merge(
+                str(merge_uid),
+                for_update=True,
+            )
+            if merge is None:
+                raise DeviceEntityNotFound(f"merge {merge_uid} was not found")
+            if self.repository.list_rollbacks(merge.uid):
+                raise DeviceEntityConflict("merge was already rolled back")
+            candidate = self._candidate(
+                merge.candidate_uid,
+                for_update=True,
+            )
+            if candidate.status != "merged":
+                raise DeviceEntityConflict("candidate is not actively merged")
+            if candidate.current_version != expected_version:
+                raise DeviceEntityConflict("candidate version conflict")
+            now = self.now_factory()
+            rollback = DeviceEntityRollbackRecord(
+                uid=self.uid_factory(),
+                merge_uid=merge.uid,
+                candidate_uid=candidate.uid,
+                reason=reason,
+                snapshot={
+                    "merge": {
+                        "uid": merge.uid,
+                        "canonical_asset_uid": merge.canonical_asset_uid,
+                        "member_asset_uid": merge.member_asset_uid,
+                        "review_uid": merge.review_uid,
+                        "created_at": (
+                            merge.created_at.isoformat()
+                            if merge.created_at
+                            else None
+                        ),
+                    },
+                    "candidate_version_before": candidate.current_version,
+                },
+                actor_uid=actor_uid,
+                created_at=now,
+            )
+            rollback = self.repository.create_rollback(rollback)
+            candidate = replace(
+                candidate,
+                status="rolled_back",
+                current_version=candidate.current_version + 1,
+                reviewed_by=actor_uid,
+                updated_at=now,
+            )
+            candidate = self.repository.update_candidate(candidate)
+            self.commit()
+            return candidate, rollback
+        except Exception:
+            self.rollback_transaction()
+            raise
+
+    def search(self, filters, *, page=1, page_size=20):
+        if not isinstance(filters, dict):
+            raise DeviceEntityInvalid("filters must be an object")
+        normalized = {}
+        status = str(filters.get("status") or "").strip()
+        if status:
+            if status not in {"pending", "merged", "rejected", "rolled_back"}:
+                raise DeviceEntityInvalid("status is not supported")
+            normalized["status"] = status
+        source = str(filters.get("suggestion_source") or "").strip()
+        if source:
+            if source not in {"rule", "ai", "manual"}:
+                raise DeviceEntityInvalid("suggestion_source is not supported")
+            normalized["suggestion_source"] = source
+        page = _positive_int(
+            {"page": page},
+            "page",
+            default=1,
+            maximum=1_000_000,
+        )
+        page_size = _positive_int(
+            {"page_size": page_size},
+            "page_size",
+            default=20,
+            maximum=MAX_PAGE_SIZE,
+        )
+        return self.repository.search_candidates(
+            normalized,
+            page=page,
+            page_size=page_size,
+        )
+
+    def get(self, candidate_uid):
+        return self._candidate(candidate_uid)
+
+    def reviews(self, candidate_uid):
+        self._candidate(candidate_uid)
+        return self.repository.list_reviews(str(candidate_uid))
+
+    def merges(self, candidate_uid):
+        self._candidate(candidate_uid)
+        return self.repository.list_merges(str(candidate_uid))
+
+    def rollbacks(self, merge_uid):
+        merge = self.repository.get_merge(str(merge_uid))
+        if merge is None:
+            raise DeviceEntityNotFound(f"merge {merge_uid} was not found")
+        return self.repository.list_rollbacks(str(merge_uid))
+
+
+__all__ = [
+    "AUTO_MERGE_THRESHOLD",
+    "DeviceEntityCandidateRecord",
+    "DeviceEntityConflict",
+    "DeviceEntityForbidden",
+    "DeviceEntityGenerationResult",
+    "DeviceEntityInvalid",
+    "DeviceEntityMergeRecord",
+    "DeviceEntityNotFound",
+    "DeviceEntityResolutionService",
+    "DeviceEntityReviewRecord",
+    "DeviceEntityRollbackRecord",
+    "DevicePairScore",
+    "score_device_pair",
+]

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

@@ -78,3 +78,23 @@ class DeviceSemanticNotFound(DataResearchError):
 class DeviceSemanticConflict(DataResearchError):
     code = "DEVICE_SEMANTIC_CONFLICT"
     http_status = 409
+
+
+class DeviceEntityInvalid(DataResearchError):
+    code = "DEVICE_ENTITY_INVALID"
+    http_status = 422
+
+
+class DeviceEntityForbidden(DataResearchError):
+    code = "DEVICE_ENTITY_FORBIDDEN"
+    http_status = 403
+
+
+class DeviceEntityNotFound(DataResearchError):
+    code = "DEVICE_ENTITY_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceEntityConflict(DataResearchError):
+    code = "DEVICE_ENTITY_CONFLICT"
+    http_status = 409

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

@@ -36,6 +36,8 @@ ONTOLOGIES_PUBLISH = "ontologies:publish"
 DEVICE_ASSETS_EDIT = "device-assets:edit"
 DEVICE_SEMANTICS_EDIT = "device-semantics:edit"
 DEVICE_SEMANTICS_REVIEW = "device-semantics:review"
+DEVICE_ENTITIES_EDIT = "device-entities:edit"
+DEVICE_ENTITIES_REVIEW = "device-entities:review"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -56,6 +58,7 @@ ROLE_PERMISSIONS = {
             ONTOLOGIES_EDIT,
             DEVICE_ASSETS_EDIT,
             DEVICE_SEMANTICS_EDIT,
+            DEVICE_ENTITIES_EDIT,
         }
     ),
     "admin": frozenset(
@@ -89,6 +92,8 @@ ROLE_PERMISSIONS = {
             DEVICE_ASSETS_EDIT,
             DEVICE_SEMANTICS_EDIT,
             DEVICE_SEMANTICS_REVIEW,
+            DEVICE_ENTITIES_EDIT,
+            DEVICE_ENTITIES_REVIEW,
         }
     ),
 }
@@ -159,6 +164,12 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path.endswith("/review"):
             return (DEVICE_SEMANTICS_REVIEW,)
         return (DEVICE_SEMANTICS_EDIT,)
+    if path.startswith("/api/development/v1/device-entities"):
+        if method == "GET":
+            return (READ_GOVERNANCE,)
+        if path.endswith("/review") or path.endswith("/rollback"):
+            return (DEVICE_ENTITIES_REVIEW,)
+        return (DEVICE_ENTITIES_EDIT,)
     if path.startswith("/api/development/v1/ingestion-jobs"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 205 - 1
deployment/app/models/data_research.py

@@ -8,7 +8,6 @@ from app import db
 from app.core.common.identifiers import new_governance_uid
 from app.core.common.timezone_utils import now_china, now_china_naive
 
-
 JOB_STATUSES = (
     "created",
     "queued",
@@ -514,6 +513,211 @@ class DeviceSemanticCodeReview(db.Model):
     )
 
 
+class DeviceEntityMatchCandidate(db.Model):
+    __tablename__ = "device_entity_match_candidates"
+    __table_args__ = (
+        db.CheckConstraint(
+            "left_asset_uid <> right_asset_uid",
+            name="ck_device_entity_candidate_distinct_assets",
+        ),
+        db.CheckConstraint(
+            "status IN ('pending','merged','rejected','rolled_back')",
+            name="ck_device_entity_candidate_status",
+        ),
+        db.CheckConstraint(
+            "suggestion_source IN ('rule','ai','manual')",
+            name="ck_device_entity_candidate_source",
+        ),
+        db.CheckConstraint(
+            "confidence >= 0 AND confidence <= 1",
+            name="ck_device_entity_candidate_confidence",
+        ),
+        db.CheckConstraint(
+            "current_version > 0",
+            name="ck_device_entity_candidate_version",
+        ),
+        db.Index(
+            "uq_device_entity_open_pair",
+            "left_asset_uid",
+            "right_asset_uid",
+            unique=True,
+            postgresql_where=db.text(
+                "status IN ('pending','merged')"
+            ),
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    left_asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    right_asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    canonical_asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+    )
+    status = db.Column(db.String(20), nullable=False, default="pending")
+    suggestion_source = db.Column(db.String(20), nullable=False)
+    confidence = db.Column(db.Float, nullable=False)
+    explanation = db.Column(JSONB, nullable=False, default=list)
+    evidence_uids = db.Column(JSONB, nullable=False, default=list)
+    model_provider = db.Column(db.String(200))
+    model_name = db.Column(db.String(200))
+    current_version = db.Column(db.Integer, nullable=False, default=1)
+    created_by = db.Column(db.String(100))
+    reviewed_by = db.Column(db.String(100))
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    updated_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceEntityMatchReview(db.Model):
+    __tablename__ = "device_entity_match_reviews"
+    __table_args__ = (
+        db.CheckConstraint(
+            "version > 0",
+            name="ck_device_entity_review_version",
+        ),
+        db.CheckConstraint(
+            "decision IN ('approve','reject','auto_approve')",
+            name="ck_device_entity_review_decision",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    candidate_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_entity_match_candidates.uid",
+            ondelete="CASCADE",
+        ),
+        nullable=False,
+    )
+    version = db.Column(db.Integer, nullable=False)
+    decision = db.Column(db.String(20), nullable=False)
+    reason = db.Column(db.String(1000), nullable=False)
+    actor_uid = db.Column(db.String(100), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceEntityMergeEvent(db.Model):
+    __tablename__ = "device_entity_merge_events"
+    __table_args__ = (
+        db.UniqueConstraint(
+            "candidate_uid",
+            name="uq_device_entity_merge_candidate",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    candidate_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_entity_match_candidates.uid",
+            ondelete="CASCADE",
+        ),
+        nullable=False,
+    )
+    canonical_asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    member_asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    review_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_entity_match_reviews.uid",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    snapshot = db.Column(JSONB, nullable=False)
+    actor_uid = db.Column(db.String(100), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceEntityMergeRollback(db.Model):
+    __tablename__ = "device_entity_merge_rollbacks"
+    __table_args__ = (
+        db.UniqueConstraint(
+            "merge_uid",
+            name="uq_device_entity_merge_rollback",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    merge_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_entity_merge_events.uid",
+            ondelete="CASCADE",
+        ),
+        nullable=False,
+    )
+    candidate_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_entity_match_candidates.uid",
+            ondelete="CASCADE",
+        ),
+        nullable=False,
+    )
+    reason = db.Column(db.String(1000), nullable=False)
+    snapshot = db.Column(JSONB, nullable=False)
+    actor_uid = db.Column(db.String(100), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
 class EvidenceFragment(db.Model):
     __tablename__ = "evidence_fragments"
     __table_args__ = (

+ 1 - 0
docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md

@@ -158,6 +158,7 @@ P2 不阻塞第一阶段验收。没有完成的 P2 功能必须保留接口和
 | WP-03 | 工程完成,待企业接入 | 数据源自动登记;只读目录执行;幂等与主动重采;尝试次数、失败阶段和脱敏诊断;每次执行不可变目录快照;字段级来源证据;本地隔离 PostgreSQL/MySQL 双源实测;OpenAPI 147 项 | 需要企业设备台账库、维修库只读账号、网络、采集范围和数据字典;当前同步执行满足示范版,异步 Worker 与断点续跑保留为后续增强 |
 | WP-04 | 工程完成,待企业数据验收 | 五类设备对象规范化导入;稳定平台 UID 与来源身份唯一映射;无变化不增版、变化生成不可变版本;关键词和类型/状态/来源筛选;来源与责任信息详情;查看者只读、编辑者受控导入;OpenAPI 151 项;本地 PostgreSQL 与页面链路定向验证 | 需要企业提供设备台账与维修样本、字段映射和业务确认;跨来源自动匹配、合并与回滚归 WP-06,质量与血缘汇聚不在 WP-04 |
 | WP-05 | 工程完成,待企业语义与代码集验收 | 通用本体能力已纳入当前分支;11 类设备核心概念、10 条标准关系和平台设备身份映射;设备覆盖度及负责人发布门禁;故障/原因/措施代码不可变版本、证据、提交、审批与审计;查看者、编辑者、审批者权限分离;OpenAPI 161 项;本地 PostgreSQL 和页面链路定向验证 | 需要企业确认设备语义、故障/原因/措施代码和唯一设备资产负责人;规则/AI 建议不会自动发布;受治理 AI 提供方与企业证据集未验收,SEM-21 保持部分建设;跨来源实体匹配归 WP-06 |
+| WP-06 | 工程完成,待企业匹配阈值验收 | 跨来源同类型资产候选;名称、位置、组织、责任人、型号和来源编码逐项评分解释;规则与受治理 AI 候选;权限与唯一设备映射负责人双门禁;非破坏性主资产关联;不可变审核、合并快照和追加式回滚证据;OpenAPI 170 项;真实 PostgreSQL 定向验证 | 需要企业提供跨系统同一/不同设备标注集、确认候选阈值和唯一设备映射负责人;自动合并默认关闭且只允许确定性规则严格高置信度候选,AI 自动合并不在首期;属性幸存规则、物理删除、质量评分和下游图谱投影不在 WP-06 |
 
 ## 7. 12 周执行计划
 

+ 12 - 10
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -27,7 +27,7 @@ DataOps Platform 已从早期的“元数据管理 + n8n 工作流 + 数据订
 当前工程能力已经明显超过旧版架构总览:
 
 - Flask 当前注册 **11 个 Blueprint**。
-- 路由源码中共有 **161 个路由声明**。
+- 路由源码中共有 **170 个路由声明**。
 - 前端可见一级模块包括工作台、数据治理知识库、数据研发、数据地图、数据工厂、数据服务、数据审核和系统管理。
 - Alembic 迁移已经覆盖 RBAC、工作台、知识库、数据源凭据、工作流引擎、Runner/MCP、n8n→Kestra 迁移、数据研发采集/本体、AI 数据规则、规则运行证据和数据工厂投产。
 - 当前分支相对 `master` 领先 37 个提交;不含本报告,普查启动时还有 32 个已修改文件和 11 个未跟踪项。因此数据规则和生产线投产能力不能直接视为主分支或生产环境现状。
@@ -214,7 +214,7 @@ flowchart TB
 | 全本地 Docker 栈 | PostgreSQL/pgvector、源 PostgreSQL/MySQL、Neo4j、MinIO、n8n、Kestra、Runner、Backend、Frontend | 已建设 | [本地栈说明](../deploy/docker/README.md) |
 | 可选 MCP/知识侧车 | Kestra MCP、DataOps Context/Scheduling MCP、LightRAG 投影服务 | 已建设,按 profile 启用 | `deploy/docker/docker-compose.yml` |
 | 生产发布包 | `deployment/` 可独立部署,包含脚本、配置和发布副本 | 已建设但必须保持单向生成 | [部署源真相](architecture/DEPLOYMENT_SOURCE_OF_TRUTH.md) |
-| API 机器清单 | 自动生成 OpenAPI | 部分建设/当前已漂移 | 当前源码有 161 个路由声明,而 `OPENAPI.yaml` 仍记录 116 个,版本仍为 2026-07-16 |
+| API 机器清单 | 自动生成 OpenAPI | 工程完成,持续门禁 | 当前源码和 `OPENAPI.yaml` 均记录 170 个路由操作,由生成器和契约测试保持一致 |
 | HOPMS 数据导入 | 资源导入脚本、dry-run、导入结果和验收样例 | 已建设的离线工具 | `scripts/import_hopms_dataset.py`、`docs/generated/` |
 | n8n 资产盘点/迁移命令 | 清单、单流程迁移、双轨对账和退役审计 | 已建设 | `scripts/inventory_n8n_workflows.py`、`app/commands/migrate_n8n_workflow.py` |
 | 规则运行运维 | 健康检查、证据、canary、激活、回滚、故障处置 | 已建设 | [规则运行手册](operations/DATA_RULE_RUNTIME_RUNBOOK.md) |
@@ -320,13 +320,13 @@ flowchart TB
 
 [下一轮路线图](architecture/NEXT_ITERATION_ROADMAP.md)中的 P0–P6 也混合了已完成、分支完成和待生产接入的状态,应改造成带“状态/分支/验收/生产门禁”的动态路线图。
 
-### 7.3 OpenAPI 已与路由源码漂移
+### 7.3 OpenAPI 漂移已收口
 
-- 当前路由源码:161 个路由声明。
-- 当前 `docs/architecture/OPENAPI.yaml`:`x-route-count: 116`。
-- OpenAPI 仍标注版本 `2026-07-16`,没有完整反映数据规则和知识库新增路由。
+- 当前路由源码:170 个路由声明。
+- 当前 `docs/architecture/OPENAPI.yaml`:`x-route-count: 170`。
+- 自动生成与契约测试已覆盖数据规则、设备台账、设备语义和实体匹配新增路由。
 
-这会影响接口普查、客户端生成、契约审查和发布门禁,应优先重新生成
+后续新增路由仍必须同步再生成,并通过路由清单一致性门禁
 
 ### 7.4 工作台有两套未打通的契约
 
@@ -543,6 +543,8 @@ DataOps Platform 当前已经具备较完整的“治理对象 → 知识服务
 
 WP-04 已补齐设备垂直切片的工程拼图:设备、部件、测点、告警和维护记录可通过受控接口导入,按名称、源编码、位置、组织和负责人检索,并展示稳定平台 UID、来源映射和不可变版本。CAT-13、CAT-14、CAT-21 仍保留“部分建设”,因为跨来源实体匹配、合并/回滚、质量与血缘汇聚分别属于 WP-06、WP-07 和 WP-09;企业真实台账与维修数据尚待接入验收。
 
+WP-06 已补齐跨来源实体治理工程链:确定性规则按名称、位置、组织、责任人、型号和来源编码生成候选及逐项解释;受治理 AI 可提交带模型、置信度和证据的候选,但不能自动合并;设备资产负责人审批后只建立非破坏性主资产关联,并保留审核、合并快照和追加式回滚证据。真实企业台账的阈值、误匹配率和责任矩阵仍待现场验收。
+
 ### 12.5 数据标准、语义与本体
 
 | 模块编号 | 模块分级 | 功能项 | 成熟度 |
@@ -561,9 +563,9 @@ WP-04 已补齐设备垂直切片的工程拼图:设备、部件、测点、
 | SEM-12 | 本体治理 / 建议评审 | 接受、拒绝、修改后接受和证据追溯 | 已建设 |
 | SEM-13 | 本体治理 / 跨域本体 | 一个本体服务多个业务域及 owner/contributor/consumer 关系 | 已建设 |
 | SEM-14 | 本体治理 / 交换格式 | JSON、OWL 导入导出和后续 RDF 支持 | 部分建设;JSON/OWL 已形成,RDF 待建设 |
-| SEM-15 | 实体治理 / 实体解析 | 跨系统实体匹配、候选、置信度和解释 | 规划中 |
-| SEM-16 | 实体治理 / 自动合并 | 高置信度自动映射/合并,中低置信度人工审核 | 规划中 |
-| SEM-17 | 实体治理 / 合并回滚 | 合并前快照、撤销、冲突和下游影响 | 规划中 |
+| SEM-15 | 实体治理 / 实体解析 | 跨系统实体匹配、候选、置信度和解释 | 工程完成,待企业数据与阈值验收 |
+| SEM-16 | 实体治理 / 自动合并 | 高置信度自动映射/合并,中低置信度人工审核 | 部分建设;确定性规则自动合并默认关闭且受门禁,AI 候选保持人工审核 |
+| SEM-17 | 实体治理 / 合并回滚 | 合并前快照、撤销、冲突和下游影响 | 工程完成,待企业回滚场景验收 |
 | SEM-18 | 设备语义 / 设备主数据 | 设备、部件、位置、组织和责任人的统一模型 | 工程完成,待企业语义验收 |
 | SEM-19 | 设备语义 / 设备标识 | 建立平台设备 UID 并映射各源系统编码 | 工程完成,待企业数据验收 |
 | SEM-20 | 设备语义 / 故障分类 | 整合各系统故障、原因和措施代码 | 工程完成,待企业代码集验收 |

+ 6 - 0
docs/architecture/DATA_MODEL.md

@@ -152,6 +152,10 @@ flowchart LR
 | `device_semantic_codes` | `ontology_uid`, `code_type`, `canonical_code`, `canonical_name`, `status`, `current_version`, `source_mappings`, `evidence_uids`, `suggestion_source`, `confidence` | 故障、原因、措施规范代码;按本体、类型、代码保持唯一身份 |
 | `device_semantic_code_versions` | `code_uid`, `version`, `snapshot`, `created_by` | 设备语义代码不可变版本;修订只追加、不覆盖历史 |
 | `device_semantic_code_reviews` | `code_uid`, `version`, `decision`, `reason`, `actor_uid` | 设备资产负责人对代码版本的批准或退回审计 |
+| `device_entity_match_candidates` | `left_asset_uid`, `right_asset_uid`, `canonical_asset_uid`, `status`, `suggestion_source`, `confidence`, `explanation`, `evidence_uids`, `current_version` | 跨来源实体匹配候选;开放候选对唯一,规则与 AI 候选共用受治理生命周期 |
+| `device_entity_match_reviews` | `candidate_uid`, `version`, `decision`, `reason`, `actor_uid` | 人工批准、拒绝或严格门禁自动批准的不可变审核证据 |
+| `device_entity_merge_events` | `candidate_uid`, `canonical_asset_uid`, `member_asset_uid`, `review_uid`, `snapshot` | 非破坏性主资产关联及合并前证据快照,不改写设备资产和来源映射 |
+| `device_entity_merge_rollbacks` | `merge_uid`, `candidate_uid`, `reason`, `snapshot`, `actor_uid` | 每次合并最多一个追加式回滚事件;原合并事件保留 |
 | `ontologies` | `code`, `owner_uid`, `draft_revision`, `active_version_uid` | 本体稳定身份和生效版本 |
 | `ontology_versions` | `ontology_uid`, `version`, `parent_version_uid`, `graph_document`, `content_hash` | 不可变本体版本 |
 | `ontology_domain_links` | `ontology_uid`, `domain_uid`, `role` | 多业务域 owner/contributor/consumer 关系 |
@@ -180,6 +184,8 @@ flowchart LR
 - n8n 是 Workflow 定义与执行记录的源真相;平台保存治理映射和生效状态。
 - 本体与数据元素的发布版本以 PostgreSQL 为源真相;Neo4j 是可重建的已发布语义投影。
 - 设备资产、源编码映射和不可变版本以 PostgreSQL 为源真相;跨来源匹配、合并与回滚在 WP-06 经审核后实施。
+- 实体匹配候选、审核、主资产关联和回滚证据以 PostgreSQL 为源真相;合并是可撤销关系,不删除、不搬迁设备资产、来源映射或历史版本。
+- 规则自动合并默认关闭,只允许显式开闸后的确定性规则高置信度候选;AI 候选始终进入人工审核。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 235 - 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: 161
+x-route-count: 170
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -1839,6 +1839,240 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-entities/candidates":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_entity_candidates_get
+      summary: "list device entity candidates"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [data_development]
+      operationId: data_development_submit_device_entity_candidate_post
+      summary: "submit device entity candidate"
+      x-source: "app/api/data_development/routes.py"
+      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/development/v1/device-entities/candidates/generate":
+    post:
+      tags: [data_development]
+      operationId: data_development_generate_device_entity_candidates_post
+      summary: "generate device entity candidates"
+      x-source: "app/api/data_development/routes.py"
+      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/development/v1/device-entities/candidates/{candidate_uid}":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_device_entity_candidate_get
+      summary: "get device entity candidate"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: candidate_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'
+  "/api/development/v1/device-entities/candidates/{candidate_uid}/merges":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_entity_merges_get
+      summary: "list device entity merges"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: candidate_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'
+  "/api/development/v1/device-entities/candidates/{candidate_uid}/review":
+    post:
+      tags: [data_development]
+      operationId: data_development_review_device_entity_candidate_post
+      summary: "review device entity candidate"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: candidate_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/development/v1/device-entities/candidates/{candidate_uid}/reviews":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_entity_reviews_get
+      summary: "list device entity reviews"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: candidate_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'
+  "/api/development/v1/device-entities/merges/{merge_uid}/rollback":
+    post:
+      tags: [data_development]
+      operationId: data_development_rollback_device_entity_merge_post
+      summary: "rollback device entity merge"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: merge_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/development/v1/device-entities/merges/{merge_uid}/rollbacks":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_entity_rollbacks_get
+      summary: "list device entity rollbacks"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: merge_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'
   "/api/development/v1/device-semantics/bootstrap":
     post:
       tags: [data_development]

+ 203 - 0
docs/superpowers/plans/2026-07-29-wp06-device-entity-resolution.md

@@ -0,0 +1,203 @@
+# WP-06 Device Entity Resolution and Reversible Merge Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Generate explainable cross-source device-match candidates, govern manual or tightly gated automatic merges, and preserve complete rollback evidence without deleting source assets or source identities.
+
+**Architecture:** PostgreSQL remains authoritative for match candidates, immutable reviews, merge events, and rollback events. A merge is a non-destructive canonical/member relationship: WP-04 asset rows, versions, and source mappings remain unchanged, while the active merge event determines the canonical asset and its grouped source identities. Deterministic rules generate the first production-ready candidates; governed AI proposals may enter the same lifecycle with provider, model, evidence, and confidence, but cannot auto-merge in WP-06.
+
+**Tech Stack:** Flask, SQLAlchemy, PostgreSQL JSONB, Alembic, Vue 2, Vuetify, pytest.
+
+---
+
+## Global Constraints
+
+- Candidate pairs must have the same asset type, come from different source systems, and contain two distinct active assets.
+- Pair identity is normalized by sorted asset UID so the same pair cannot have two simultaneously open candidates.
+- Rule scoring uses independently explainable signals: normalized name, location, organization, responsible person, source code, and configured model attribute.
+- Candidate generation is bounded to at most 500 assets and 2,000 new candidates per request.
+- Rule candidates require platform asset and source-mapping evidence. AI candidates additionally require provider, model, confidence in `[0, 1]`, and evidence UIDs.
+- Credentials, tokens, connection strings, personal contact fields, and raw operating measurements are rejected and never returned.
+- Viewer may read. Editor may generate deterministic candidates and submit governed AI candidates. Only admin with `device-entities:review` plus the unique accountable `asset_manager` for `device_mapping/DEVICE_ENTITY_RESOLUTION` may approve, reject, auto-merge, or roll back.
+- Automatic merge is disabled by default. When explicitly enabled it is rule-only, requires confidence at or above `0.98`, requires the initiating actor to pass the accountable-manager gate, and writes the same review and merge evidence as a manual approval.
+- A canonical asset may have multiple members. A member may belong to only one active canonical group, and a canonical asset may not itself be an active member.
+- Merge never rewrites or deletes `device_assets`, `device_asset_source_mappings`, or `device_asset_versions`.
+- Rollback appends an immutable rollback event; it does not delete or rewrite the merge event.
+- WP-06 does not perform survivorship updates to asset attributes, physical deduplication, quality scoring, root-cause analysis, or downstream graph projection.
+- Validation follows the user-approved rule: run only WP-06 domain, persistence, API, permission, frontend, migration, contract, and local browser checks; do not run the full repository regression.
+- Continue on `codex/dataops-phase1-equipment-governance`; do not push or deploy remotely.
+
+### Task 1: Deterministic Candidates and Governed Decisions
+
+**Files:**
+- Create: `app/core/data_research/device_entity_resolution.py`
+- Modify: `app/core/data_research/errors.py`
+- Create: `tests/data_research/test_device_entity_resolution.py`
+
+**Interfaces:**
+- Produces records `DeviceEntityCandidateRecord`, `DeviceEntityReviewRecord`, `DeviceEntityMergeRecord`, and `DeviceEntityRollbackRecord`.
+- Produces `score_device_pair(left, right)`, `DeviceEntityResolutionService.generate`, `submit_ai_candidate`, `search`, `get`, `review`, `rollback`, `reviews`, `merges`, and `rollbacks`.
+- Repository boundary supplies assets with source mappings, open-pair lookup, candidate persistence, row locks, active canonical/member lookup, and immutable evidence append methods.
+
+- [x] **Step 1: Write failing scoring and lifecycle tests**
+
+Cover hand-derived score literals, Unicode/case/spacing normalization, same-source and different-type rejection, bounded generation, duplicate-open-candidate suppression, required evidence, AI-provider requirements, secret rejection, manual approval, accountable-manager rejection, automatic-merge default-off, strict rule-only automatic merge, canonical/member conflicts, immutable reviews, and append-only rollback.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+PYTHONPATH=. .venv/bin/pytest -q \
+  tests/data_research/test_device_entity_resolution.py
+```
+
+Expected: collection fails because `device_entity_resolution` does not exist.
+
+- [x] **Step 3: Implement the minimal domain service**
+
+Implement deterministic normalization and signal scoring without external model calls. Store explanations as a list of `{signal, matched, weight, left, right}` entries and evidence as bounded UID lists. Treat approval as an atomic review plus non-destructive merge event; reject invalid transitions and stale versions with `409`.
+
+- [x] **Step 4: Verify GREEN**
+
+Run the Task 1 test command and expect all domain tests to pass.
+
+### Task 2: PostgreSQL Candidate, Merge, and Rollback Ledger
+
+**Files:**
+- Create: `app/core/data_research/device_entity_repository.py`
+- Modify: `app/models/data_research.py`
+- Create: `migrations/versions/20260729_310_device_entity_resolution.py`
+- Modify: `tests/test_database_migrations.py`
+- Create: `tests/integration/test_device_entity_resolution_postgres.py`
+
+**Interfaces:**
+- Produces `SqlAlchemyDeviceEntityResolutionRepository`.
+- Adds `device_entity_match_candidates`, `device_entity_match_reviews`, `device_entity_merge_events`, and `device_entity_merge_rollbacks`.
+- Candidate status is `pending|merged|rejected|rolled_back`; suggestion source is `rule|ai|manual`; review decision is `approve|reject|auto_approve`.
+- Active membership is a merge event without a corresponding rollback event.
+
+- [x] **Step 1: Write failing migration and real-PostgreSQL tests**
+
+Cover database constraints, pair lookup, bounded filters, row locks, multiple members under one canonical asset, single active canonical per member, merge snapshots, immutable review records, immutable rollback records, and cleanup limited to test-owned rows.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+TEST_DATABASE_URL=postgresql+psycopg2://dataops:dataops-test-password@127.0.0.1:15432/dataops \
+PYTHONPATH=. .venv/bin/pytest -q \
+  tests/test_database_migrations.py::test_device_entity_resolution_migration_is_non_destructive_and_reversible \
+  tests/integration/test_device_entity_resolution_postgres.py
+```
+
+Expected: failure because the migration and repository do not exist.
+
+- [x] **Step 3: Implement migration, models, and repository**
+
+Use UUIDv7 identities, JSONB evidence/explanations/snapshots, a partial unique index for one open pair, transactional active-member conflict checks, and `FOR UPDATE` for review and rollback. Keep downgrade data-preserving.
+
+- [x] **Step 4: Upgrade local PostgreSQL and verify GREEN**
+
+Upgrade the isolated local database to `20260729_310`, then rerun the Task 2 command with `TEST_DATABASE_URL`.
+
+### Task 3: Permission-Controlled Entity-Resolution API
+
+**Files:**
+- Modify: `app/api/data_development/routes.py`
+- Modify: `app/core/system/permissions.py`
+- Create: `tests/data_research/test_device_entity_resolution_api.py`
+- Modify: `tests/test_permission_matrix.py`
+
+**Interfaces:**
+- Produces:
+  - `GET /api/development/v1/device-entities/candidates`
+  - `POST /api/development/v1/device-entities/candidates/generate`
+  - `POST /api/development/v1/device-entities/candidates`
+  - `GET /api/development/v1/device-entities/candidates/{candidate_uid}`
+  - `POST /api/development/v1/device-entities/candidates/{candidate_uid}/review`
+  - `GET /api/development/v1/device-entities/candidates/{candidate_uid}/reviews`
+  - `GET /api/development/v1/device-entities/candidates/{candidate_uid}/merges`
+  - `POST /api/development/v1/device-entities/merges/{merge_uid}/rollback`
+  - `GET /api/development/v1/device-entities/merges/{merge_uid}/rollbacks`
+
+- [x] **Step 1: Write failing API and permission tests**
+
+Cover viewer reads, viewer write denial, editor generation/AI submission, editor review denial, admin platform permission plus accountable-manager runtime denial, safe bounded filters, automatic-merge feature flag serialization, review/merge/rollback evidence, and safe error envelopes.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+PYTHONPATH=. .venv/bin/pytest -q \
+  tests/data_research/test_device_entity_resolution_api.py \
+  tests/test_permission_matrix.py
+```
+
+Expected: failure because the routes and dedicated permissions do not exist.
+
+- [x] **Step 3: Implement API and authorization**
+
+Add `device-entities:edit` to editor/admin and `device-entities:review` to admin. Reads remain on `governance:read`; `/review` and `/rollback` require the review permission, while the service repeats the unique accountable-manager runtime check for `device_mapping/DEVICE_ENTITY_RESOLUTION`.
+
+- [x] **Step 4: Verify GREEN**
+
+Run the Task 3 command and expect all tests to pass.
+
+### Task 4: Entity-Resolution Workbench and Delivery Evidence
+
+**Files:**
+- Modify: `frontend/src/api/dataDevelopment.js`
+- Modify: `frontend/src/router/routes.js`
+- Modify: `frontend/src/views/dataGovernance/development/index.vue`
+- Create: `frontend/src/views/dataGovernance/development/deviceEntityResolution.vue`
+- Create: `frontend/src/views/dataGovernance/development/deviceEntityResolutionModel.js`
+- Create: `frontend/tests/device-entity-resolution-model.test.mjs`
+- Modify: `docs/architecture/OPENAPI.yaml`
+- Modify: `docs/architecture/DATA_MODEL.md`
+- Modify: `docs/FUNCTION_MODULE_CENSUS_20260726.md`
+- Modify: `docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md`
+- Modify: `deployment/app/` for the WP-06 backend subset.
+
+**Interfaces:**
+- Produces `/data-governance/development/entity-resolution` with candidate status filters, explainable signal comparison, evidence display, generate action, permission-aware approve/reject controls, canonical-asset selection, merge history, and rollback evidence.
+
+- [x] **Step 1: Write failing frontend model tests**
+
+Cover status and source labels, confidence formatting, matched-signal summary, canonical/member selection, and permission-aware review/rollback decisions.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+cd frontend
+node --test tests/device-entity-resolution-model.test.mjs
+```
+
+Expected: failure because the frontend model module does not exist.
+
+- [x] **Step 3: Implement the workbench**
+
+Add the development-center entry and responsive workbench. Keep candidate evidence and lifecycle visible to viewers; hide mutation actions without permissions; display that AI candidates always require manual review and that physical deduplication/survivorship remains outside WP-06.
+
+- [x] **Step 4: Regenerate contracts and update ledgers**
+
+Run:
+
+```bash
+.venv/bin/python scripts/generate_openapi.py \
+  --output docs/architecture/OPENAPI.yaml
+```
+
+Record SEM-15 and SEM-17 as engineering complete pending enterprise data acceptance. Record SEM-16 as partially built because deterministic high-confidence auto-merge is feature-gated and governed AI auto-merge remains intentionally disabled.
+
+- [x] **Step 5: Run targeted verification**
+
+Run only the WP-06 domain, PostgreSQL, API, permission, migration, OpenAPI, frontend model, targeted lint, production build, release-copy parity, and local browser workflow. In the browser generate a cross-source candidate, inspect its score explanation, approve it as the accountable asset manager, confirm grouped identities, roll it back, and confirm zero console errors. Run `git diff --check`.
+
+- [x] **Step 6: Commit**
+
+Create one independently reversible WP-06 engineering commit. Do not push.

+ 43 - 1
frontend/src/api/dataDevelopment.js

@@ -4,6 +4,7 @@ const BASE = '/development/v1/ingestion-jobs'
 const ONTOLOGY_BASE = '/development/v1/ontologies'
 const DEVICE_ASSET_BASE = '/development/v1/device-assets'
 const DEVICE_SEMANTIC_BASE = '/development/v1/device-semantics'
+const DEVICE_ENTITY_BASE = '/development/v1/device-entities'
 
 export const createIngestionJob = params => http.post(BASE, params)
 export const getIngestionJobs = params => http.get(BASE, params)
@@ -109,6 +110,38 @@ export const getDeviceSemanticCodeVersions = uid => http.get(
 export const getDeviceSemanticCodeReviews = uid => http.get(
   `${DEVICE_SEMANTIC_BASE}/codes/${uid}/reviews`
 )
+export const getDeviceEntityCandidates = params => http.get(
+  `${DEVICE_ENTITY_BASE}/candidates`,
+  params
+)
+export const getDeviceEntityCandidate = uid => http.get(
+  `${DEVICE_ENTITY_BASE}/candidates/${uid}`
+)
+export const generateDeviceEntityCandidates = params => http.post(
+  `${DEVICE_ENTITY_BASE}/candidates/generate`,
+  params
+)
+export const submitDeviceEntityCandidate = params => http.post(
+  `${DEVICE_ENTITY_BASE}/candidates`,
+  params
+)
+export const reviewDeviceEntityCandidate = (uid, params) => http.post(
+  `${DEVICE_ENTITY_BASE}/candidates/${uid}/review`,
+  params
+)
+export const getDeviceEntityReviews = uid => http.get(
+  `${DEVICE_ENTITY_BASE}/candidates/${uid}/reviews`
+)
+export const getDeviceEntityMerges = uid => http.get(
+  `${DEVICE_ENTITY_BASE}/candidates/${uid}/merges`
+)
+export const rollbackDeviceEntityMerge = (uid, params) => http.post(
+  `${DEVICE_ENTITY_BASE}/merges/${uid}/rollback`,
+  params
+)
+export const getDeviceEntityRollbacks = uid => http.get(
+  `${DEVICE_ENTITY_BASE}/merges/${uid}/rollbacks`
+)
 
 export default {
   createIngestionJob,
@@ -151,5 +184,14 @@ export default {
   submitDeviceSemanticCode,
   reviewDeviceSemanticCode,
   getDeviceSemanticCodeVersions,
-  getDeviceSemanticCodeReviews
+  getDeviceSemanticCodeReviews,
+  getDeviceEntityCandidates,
+  getDeviceEntityCandidate,
+  generateDeviceEntityCandidates,
+  submitDeviceEntityCandidate,
+  reviewDeviceEntityCandidate,
+  getDeviceEntityReviews,
+  getDeviceEntityMerges,
+  rollbackDeviceEntityMerge,
+  getDeviceEntityRollbacks
 }

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

@@ -228,6 +228,18 @@ export default {
           name: 'dataResearchDeviceAssets',
           alwaysShow: 0
         },
+        {
+          hidden: 1,
+          type: 1,
+          title: '实体匹配',
+          path: '/data-governance/development/entity-resolution',
+          children: [],
+          label: '实体匹配',
+          component: 'dataGovernance/development/deviceEntityResolution',
+          meta: { roles: ['viewer', 'editor', 'admin'], title: '实体匹配与回滚', readOnly: 'viewer' },
+          name: 'deviceEntityResolution',
+          alwaysShow: 0
+        },
         {
           hidden: 1,
           type: 1,

+ 671 - 0
frontend/src/views/dataGovernance/development/deviceEntityResolution.vue

@@ -0,0 +1,671 @@
+<template>
+  <div class="pa-6 entity-resolution">
+    <div class="d-flex flex-wrap align-center mb-5">
+      <div>
+        <h1 class="text-h4 mb-1">设备实体匹配与回滚</h1>
+        <div class="text--secondary">
+          生成可解释的跨来源候选,经设备资产负责人审核后建立非破坏性主资产关联。
+        </div>
+      </div>
+      <v-spacer />
+      <v-btn
+        v-if="canEdit"
+        outlined
+        color="primary"
+        class="mr-2"
+        @click="aiDialog = true"
+      >
+        提交 AI 候选
+      </v-btn>
+      <v-btn
+        v-if="canEdit"
+        color="primary"
+        @click="generateDialog = true"
+      >
+        生成规则候选
+      </v-btn>
+    </div>
+
+    <v-alert type="info" outlined class="mb-5">
+      合并不会删除或改写设备台账、来源编码和历史版本。AI 候选始终需要人工审核;
+      高置信度规则自动合并当前
+      <strong>{{ autoMergeEnabled ? '已显式启用' : '默认关闭' }}</strong>。
+    </v-alert>
+
+    <v-card outlined class="mb-5">
+      <v-card-text>
+        <v-row dense align="center">
+          <v-col cols="12" sm="4">
+            <v-select
+              v-model="filters.status"
+              :items="statusOptions"
+              item-text="text"
+              item-value="value"
+              label="候选状态"
+              clearable
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" sm="4">
+            <v-select
+              v-model="filters.suggestion_source"
+              :items="sourceOptions"
+              item-text="text"
+              item-value="value"
+              label="建议来源"
+              clearable
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" sm="4">
+            <v-btn color="primary" class="mr-2" @click="applyFilters">查询</v-btn>
+            <v-btn text @click="resetFilters">重置</v-btn>
+          </v-col>
+        </v-row>
+      </v-card-text>
+    </v-card>
+
+    <v-card outlined>
+      <v-data-table
+        :headers="headers"
+        :items="items"
+        :loading="loading"
+        :items-per-page="pageSize"
+        hide-default-footer
+      >
+        <template v-slot:[`item.pair`]="{ item }">
+          <div class="py-2 mono">
+            <div>左:{{ item.left_asset_uid }}</div>
+            <div>右:{{ item.right_asset_uid }}</div>
+          </div>
+        </template>
+        <template v-slot:[`item.confidence`]="{ item }">
+          <strong>{{ confidenceLabel(item.confidence) }}</strong>
+          <div class="text-caption text--secondary">
+            {{ signalSummary(item).matched }}/{{ signalSummary(item).total }}
+            项信号一致
+          </div>
+        </template>
+        <template v-slot:[`item.suggestion_source`]="{ item }">
+          <div>{{ suggestionSourceLabel(item.suggestion_source) }}</div>
+          <div v-if="item.model_name" class="text-caption text--secondary">
+            {{ item.model_provider }} / {{ item.model_name }}
+          </div>
+        </template>
+        <template v-slot:[`item.status`]="{ item }">
+          <v-chip
+            small
+            :color="candidateStatusColor(item.status)"
+            text-color="white"
+          >
+            {{ candidateStatusLabel(item.status) }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn text small color="primary" @click="openDetail(item)">
+            查看证据
+          </v-btn>
+          <v-btn
+            v-if="canReview && item.status === 'pending'"
+            text
+            small
+            color="success"
+            @click="openReview(item)"
+          >
+            审核
+          </v-btn>
+        </template>
+        <template v-slot:no-data>
+          <div class="py-10 text--secondary">暂无实体匹配候选</div>
+        </template>
+      </v-data-table>
+      <v-divider />
+      <div class="d-flex align-center pa-4">
+        <span class="text-caption text--secondary">
+          第 {{ page }} 页,每页 {{ pageSize }} 项
+        </span>
+        <v-spacer />
+        <v-pagination
+          v-model="page"
+          :length="pageCount"
+          :total-visible="7"
+          @input="load"
+        />
+      </div>
+    </v-card>
+
+    <v-dialog v-model="detailDialog" max-width="1050" scrollable>
+      <v-card>
+        <v-card-title>
+          候选证据与处理记录
+          <v-spacer />
+          <v-btn icon @click="detailDialog = false"><v-icon>mdi-close</v-icon></v-btn>
+        </v-card-title>
+        <v-divider />
+        <v-card-text class="pt-5">
+          <v-progress-linear v-if="detailLoading" indeterminate />
+          <template v-else-if="selected">
+            <v-row>
+              <v-col cols="12" md="6">
+                <div class="field-label">左侧资产</div>
+                <div class="mono">{{ selected.left_asset_uid }}</div>
+              </v-col>
+              <v-col cols="12" md="6">
+                <div class="field-label">右侧资产</div>
+                <div class="mono">{{ selected.right_asset_uid }}</div>
+              </v-col>
+            </v-row>
+            <h3 class="text-subtitle-1 font-weight-bold mt-5 mb-2">
+              评分解释
+            </h3>
+            <v-simple-table dense>
+              <thead>
+                <tr>
+                  <th>信号</th>
+                  <th>是否一致</th>
+                  <th>权重</th>
+                  <th>左侧值</th>
+                  <th>右侧值</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="signal in selected.explanation" :key="signal.signal">
+                  <td>{{ signal.signal }}</td>
+                  <td>
+                    <v-icon small :color="signal.matched ? 'success' : 'grey'">
+                      {{ signal.matched ? 'mdi-check-circle' : 'mdi-minus-circle' }}
+                    </v-icon>
+                  </td>
+                  <td>{{ confidenceLabel(signal.weight) }}</td>
+                  <td>{{ signal.left || '—' }}</td>
+                  <td>{{ signal.right || '—' }}</td>
+                </tr>
+              </tbody>
+            </v-simple-table>
+            <h3 class="text-subtitle-1 font-weight-bold mt-5 mb-2">证据 UID</h3>
+            <v-chip
+              v-for="uid in selected.evidence_uids"
+              :key="uid"
+              small
+              outlined
+              class="mr-2 mb-2 mono"
+            >
+              {{ uid }}
+            </v-chip>
+            <h3 class="text-subtitle-1 font-weight-bold mt-5 mb-2">审核记录</h3>
+            <v-alert v-if="!reviews.length" dense outlined type="info">尚无审核记录</v-alert>
+            <v-list v-else dense>
+              <v-list-item v-for="item in reviews" :key="item.uid">
+                <v-list-item-content>
+                  <v-list-item-title>
+                    {{ item.decision }} · {{ item.reason }}
+                  </v-list-item-title>
+                  <v-list-item-subtitle>
+                    {{ item.actor_uid }} · {{ displayTime(item.created_at) }}
+                  </v-list-item-subtitle>
+                </v-list-item-content>
+              </v-list-item>
+            </v-list>
+            <h3 class="text-subtitle-1 font-weight-bold mt-5 mb-2">合并与回滚</h3>
+            <v-alert v-if="!merges.length" dense outlined type="info">尚无合并记录</v-alert>
+            <v-card
+              v-for="merge in merges"
+              :key="merge.uid"
+              outlined
+              class="pa-4 mb-3"
+            >
+              <div class="mono">主资产:{{ merge.canonical_asset_uid }}</div>
+              <div class="mono">成员资产:{{ merge.member_asset_uid }}</div>
+              <div class="text-caption text--secondary mt-1">
+                {{ displayTime(merge.created_at) }}
+              </div>
+              <v-btn
+                v-if="canReview && selected.status === 'merged'"
+                small
+                outlined
+                color="error"
+                class="mt-3"
+                @click="openRollback(merge)"
+              >
+                回滚合并
+              </v-btn>
+              <v-alert
+                v-for="rollback in rollbackMap[merge.uid] || []"
+                :key="rollback.uid"
+                dense
+                outlined
+                type="warning"
+                class="mt-3 mb-0"
+              >
+                {{ rollback.reason }} · {{ displayTime(rollback.created_at) }}
+              </v-alert>
+            </v-card>
+          </template>
+        </v-card-text>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="generateDialog" max-width="580">
+      <v-card>
+        <v-card-title>生成确定性规则候选</v-card-title>
+        <v-card-text>
+          <v-select
+            v-model="generateForm.asset_type"
+            :items="typeOptions"
+            item-text="text"
+            item-value="value"
+            label="资产类型"
+          />
+          <v-text-field
+            v-model.number="generateForm.threshold"
+            type="number"
+            min="0"
+            max="1"
+            step="0.01"
+            label="最低置信度"
+          />
+          <v-text-field
+            v-model.number="generateForm.limit"
+            type="number"
+            min="1"
+            max="500"
+            label="本次最多评估资产数"
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="generateDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="saving" @click="generate">
+            开始生成
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="aiDialog" max-width="680" scrollable>
+      <v-card>
+        <v-card-title>提交受治理 AI 候选</v-card-title>
+        <v-card-text>
+          <v-alert dense outlined type="warning">
+            AI 候选不会自动合并,必须由唯一设备资产负责人审批。
+          </v-alert>
+          <v-text-field v-model.trim="aiForm.left_asset_uid" label="左侧资产 UID" />
+          <v-text-field v-model.trim="aiForm.right_asset_uid" label="右侧资产 UID" />
+          <v-row>
+            <v-col cols="12" sm="6">
+              <v-text-field v-model.trim="aiForm.model_provider" label="受治理模型提供方" />
+            </v-col>
+            <v-col cols="12" sm="6">
+              <v-text-field v-model.trim="aiForm.model_name" label="模型名称" />
+            </v-col>
+          </v-row>
+          <v-text-field
+            v-model.number="aiForm.confidence"
+            type="number"
+            min="0"
+            max="1"
+            step="0.01"
+            label="置信度"
+          />
+          <v-textarea v-model.trim="aiForm.explanation" label="解释" rows="3" />
+          <v-textarea
+            v-model="aiEvidenceText"
+            label="证据 UID"
+            hint="每行一个"
+            persistent-hint
+            rows="3"
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="aiDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="saving" @click="submitAi">
+            提交候选
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="reviewDialog" max-width="620">
+      <v-card>
+        <v-card-title>审核实体匹配候选</v-card-title>
+        <v-card-text>
+          <v-select
+            v-model="reviewForm.decision"
+            :items="reviewOptions"
+            item-text="text"
+            item-value="value"
+            label="审核决定"
+          />
+          <v-select
+            v-if="reviewForm.decision === 'approve'"
+            v-model="reviewForm.canonical_asset_uid"
+            :items="canonicalChoices"
+            item-text="text"
+            item-value="value"
+            label="主资产"
+          />
+          <v-textarea v-model.trim="reviewForm.reason" label="审核依据" rows="3" />
+          <v-alert dense outlined type="info">
+            后端将再次校验你是否为 device_mapping / DEVICE_ENTITY_RESOLUTION
+            唯一最终负责的设备资产管理员。
+          </v-alert>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="reviewDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="saving" @click="review">
+            确认
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="rollbackDialog" max-width="560">
+      <v-card>
+        <v-card-title>回滚实体合并</v-card-title>
+        <v-card-text>
+          <v-textarea v-model.trim="rollbackReason" label="回滚原因" rows="3" />
+          <v-alert dense outlined type="warning">
+            回滚会追加不可变证据,不会删除原合并记录。
+          </v-alert>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="rollbackDialog = false">取消</v-btn>
+          <v-btn color="error" :loading="saving" @click="rollbackMerge">
+            确认回滚
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  generateDeviceEntityCandidates,
+  getDeviceEntityCandidate,
+  getDeviceEntityCandidates,
+  getDeviceEntityMerges,
+  getDeviceEntityReviews,
+  getDeviceEntityRollbacks,
+  reviewDeviceEntityCandidate,
+  rollbackDeviceEntityMerge,
+  submitDeviceEntityCandidate
+} from '@/api/dataDevelopment'
+import {
+  canEditDeviceEntities,
+  canReviewDeviceEntities,
+  candidateStatusColor,
+  candidateStatusLabel,
+  canonicalOptions,
+  confidenceLabel,
+  matchedSignalSummary,
+  suggestionSourceLabel
+} from './deviceEntityResolutionModel'
+
+export default {
+  name: 'DeviceEntityResolution',
+  data: () => ({
+    loading: false,
+    saving: false,
+    detailLoading: false,
+    items: [],
+    total: 0,
+    page: 1,
+    pageSize: 20,
+    autoMergeEnabled: false,
+    filters: { status: null, suggestion_source: null },
+    selected: null,
+    selectedMerge: null,
+    reviews: [],
+    merges: [],
+    rollbackMap: {},
+    detailDialog: false,
+    generateDialog: false,
+    aiDialog: false,
+    reviewDialog: false,
+    rollbackDialog: false,
+    rollbackReason: '',
+    aiEvidenceText: '',
+    generateForm: { asset_type: 'device', threshold: 0.7, limit: 500 },
+    aiForm: {
+      left_asset_uid: '',
+      right_asset_uid: '',
+      confidence: 0.8,
+      model_provider: '',
+      model_name: '',
+      explanation: ''
+    },
+    reviewForm: {
+      decision: 'approve',
+      canonical_asset_uid: '',
+      reason: ''
+    },
+    typeOptions: [
+      { text: '设备', value: 'device' },
+      { text: '部件', value: 'component' },
+      { text: '测点', value: 'measurement_point' },
+      { text: '告警', value: 'alarm' },
+      { text: '维护记录', value: 'maintenance_record' }
+    ],
+    statusOptions: [
+      { text: '待审核', value: 'pending' },
+      { text: '已合并', value: 'merged' },
+      { text: '已拒绝', value: 'rejected' },
+      { text: '已回滚', value: 'rolled_back' }
+    ],
+    sourceOptions: [
+      { text: '确定性规则', value: 'rule' },
+      { text: 'AI 建议', value: 'ai' },
+      { text: '人工候选', value: 'manual' }
+    ],
+    reviewOptions: [
+      { text: '批准并建立主资产关联', value: 'approve' },
+      { text: '拒绝候选', value: 'reject' }
+    ],
+    headers: [
+      { text: '候选资产对', value: 'pair', sortable: false },
+      { text: '置信度 / 一致信号', value: 'confidence' },
+      { text: '建议来源', value: 'suggestion_source' },
+      { text: '状态', value: 'status' },
+      { text: '版本', value: 'current_version' },
+      { text: '操作', value: 'actions', sortable: false, width: 180 }
+    ]
+  }),
+  computed: {
+    permissions () {
+      return (this.$store.state.user.userInfo || {}).permissions || []
+    },
+    canEdit () {
+      return canEditDeviceEntities(this.permissions)
+    },
+    canReview () {
+      return canReviewDeviceEntities(this.permissions)
+    },
+    pageCount () {
+      return Math.max(1, Math.ceil(this.total / this.pageSize))
+    },
+    canonicalChoices () {
+      return canonicalOptions(this.selected)
+    }
+  },
+  created () {
+    this.load()
+  },
+  methods: {
+    candidateStatusColor,
+    candidateStatusLabel,
+    confidenceLabel,
+    suggestionSourceLabel,
+    signalSummary (item) {
+      return matchedSignalSummary(item.explanation)
+    },
+    async load () {
+      this.loading = true
+      try {
+        const response = await getDeviceEntityCandidates({
+          ...this.filters,
+          page: this.page,
+          page_size: this.pageSize
+        })
+        const data = response.data || {}
+        this.items = data.records || []
+        this.total = data.total || 0
+        this.autoMergeEnabled = Boolean(data.auto_merge_enabled)
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loading = false
+      }
+    },
+    applyFilters () {
+      this.page = 1
+      this.load()
+    },
+    resetFilters () {
+      this.filters = { status: null, suggestion_source: null }
+      this.applyFilters()
+    },
+    async generate () {
+      this.saving = true
+      try {
+        const response = await generateDeviceEntityCandidates(this.generateForm)
+        const data = response.data || {}
+        this.$snackbar.success(
+          `已生成 ${data.created_count || 0} 个候选,复用 ${data.existing_count || 0} 个开放候选`
+        )
+        this.generateDialog = false
+        await this.load()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    async submitAi () {
+      this.saving = true
+      try {
+        await submitDeviceEntityCandidate({
+          ...this.aiForm,
+          evidence_uids: this.aiEvidenceText
+            .split('\n')
+            .map(item => item.trim())
+            .filter(Boolean)
+        })
+        this.$snackbar.success('AI 候选已提交,等待人工审核')
+        this.aiDialog = false
+        await this.load()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    openReview (item) {
+      this.selected = item
+      this.reviewForm = {
+        decision: 'approve',
+        canonical_asset_uid: item.left_asset_uid,
+        reason: ''
+      }
+      this.reviewDialog = true
+    },
+    async review () {
+      this.saving = true
+      try {
+        await reviewDeviceEntityCandidate(this.selected.uid, {
+          ...this.reviewForm,
+          expected_version: this.selected.current_version
+        })
+        this.$snackbar.success(
+          this.reviewForm.decision === 'approve' ? '候选已批准并建立关联' : '候选已拒绝'
+        )
+        this.reviewDialog = false
+        await this.load()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    async openDetail (item) {
+      this.detailDialog = true
+      this.detailLoading = true
+      this.selected = null
+      this.reviews = []
+      this.merges = []
+      this.rollbackMap = {}
+      try {
+        const [detail, reviews, merges] = await Promise.all([
+          getDeviceEntityCandidate(item.uid),
+          getDeviceEntityReviews(item.uid),
+          getDeviceEntityMerges(item.uid)
+        ])
+        this.selected = detail.data
+        this.reviews = (reviews.data && reviews.data.records) || []
+        this.merges = (merges.data && merges.data.records) || []
+        const rollbackEntries = await Promise.all(
+          this.merges.map(async merge => {
+            const response = await getDeviceEntityRollbacks(merge.uid)
+            return [merge.uid, (response.data && response.data.records) || []]
+          })
+        )
+        this.rollbackMap = Object.fromEntries(rollbackEntries)
+      } catch (error) {
+        this.$snackbar.error(error)
+        this.detailDialog = false
+      } finally {
+        this.detailLoading = false
+      }
+    },
+    openRollback (merge) {
+      this.selectedMerge = merge
+      this.rollbackReason = ''
+      this.rollbackDialog = true
+    },
+    async rollbackMerge () {
+      this.saving = true
+      try {
+        await rollbackDeviceEntityMerge(this.selectedMerge.uid, {
+          expected_version: this.selected.current_version,
+          reason: this.rollbackReason
+        })
+        this.$snackbar.success('合并已回滚,原始资产和来源身份保持不变')
+        this.rollbackDialog = false
+        const current = { ...this.selected, status: 'rolled_back' }
+        await this.load()
+        await this.openDetail(current)
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    displayTime (value) {
+      if (!value) return '—'
+      return new Date(value).toLocaleString('zh-CN', { hour12: false })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.entity-resolution {
+  max-width: 1500px;
+  margin: 0 auto;
+}
+
+.field-label {
+  color: rgba(0, 0, 0, 0.6);
+  font-size: 12px;
+  margin-bottom: 4px;
+}
+
+.mono {
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 12px;
+  word-break: break-all;
+}
+</style>

+ 73 - 0
frontend/src/views/dataGovernance/development/deviceEntityResolutionModel.js

@@ -0,0 +1,73 @@
+const STATUS_LABELS = {
+  pending: '待审核',
+  merged: '已合并',
+  rejected: '已拒绝',
+  rolled_back: '已回滚'
+}
+
+const STATUS_COLORS = {
+  pending: 'amber darken-2',
+  merged: 'success',
+  rejected: 'error',
+  rolled_back: 'blue-grey'
+}
+
+const SOURCE_LABELS = {
+  rule: '确定性规则',
+  ai: 'AI 建议',
+  manual: '人工候选'
+}
+
+export function canEditDeviceEntities (permissions) {
+  return (permissions || []).includes('device-entities:edit')
+}
+
+export function canReviewDeviceEntities (permissions) {
+  return (permissions || []).includes('device-entities:review')
+}
+
+export function candidateStatusLabel (value) {
+  return STATUS_LABELS[value] || value || '—'
+}
+
+export function candidateStatusColor (value) {
+  return STATUS_COLORS[value] || 'grey'
+}
+
+export function suggestionSourceLabel (value) {
+  return SOURCE_LABELS[value] || value || '—'
+}
+
+export function confidenceLabel (value) {
+  if (value === null || value === undefined || Number.isNaN(Number(value))) {
+    return '—'
+  }
+  return `${(Number(value) * 100).toFixed(1)}%`
+}
+
+export function matchedSignalSummary (explanation) {
+  const records = explanation || []
+  const matched = records.filter(item => item.matched)
+  return {
+    matched: matched.length,
+    total: records.length,
+    weight: Number(matched.reduce(
+      (total, item) => total + Number(item.weight || 0),
+      0
+    ).toFixed(6))
+  }
+}
+
+export function canonicalOptions (candidate) {
+  const value = candidate || {}
+  return [
+    {
+      text: `左侧资产 · ${value.left_asset_uid || '—'}`,
+      value: value.left_asset_uid
+    },
+    {
+      text: `右侧资产 · ${value.right_asset_uid || '—'}`,
+      value: value.right_asset_uid
+    }
+  ].filter(item => item.value)
+}

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

@@ -25,6 +25,7 @@ export default {
       { title: '研发任务', description: '查看解析进度、失败阶段与重试状态', icon: 'mdi-progress-clock', path: '/data-governance/development/tasks' },
       { title: '治理评审', description: '证据预览、批量决策与数据元素生命周期', icon: 'mdi-clipboard-check-outline', path: '/data-governance/development/review' },
       { title: '设备台账', description: '设备、部件、测点、告警与维护记录统一追溯', icon: 'mdi-factory', path: '/data-governance/development/device-assets' },
+      { title: '实体匹配', description: '跨来源匹配候选、审核、非破坏性合并与回滚', icon: 'mdi-vector-link', path: '/data-governance/development/entity-resolution' },
       { title: '本体中心', description: '跨业务域本体定义、校验、发布与回滚', icon: 'mdi-graph-outline', path: '/data-governance/ontology' }
     ]
   })

+ 55 - 0
frontend/tests/device-entity-resolution-model.test.mjs

@@ -0,0 +1,55 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+  canEditDeviceEntities,
+  canReviewDeviceEntities,
+  candidateStatusLabel,
+  confidenceLabel,
+  canonicalOptions,
+  matchedSignalSummary,
+  suggestionSourceLabel
+} from '../src/views/dataGovernance/development/deviceEntityResolutionModel.js'
+
+test('separates candidate editing from accountable review and rollback', () => {
+  assert.equal(canEditDeviceEntities(['device-entities:edit']), true)
+  assert.equal(canEditDeviceEntities(['governance:read']), false)
+  assert.equal(canReviewDeviceEntities(['device-entities:review']), true)
+  assert.equal(canReviewDeviceEntities(['device-entities:edit']), false)
+})
+
+test('presents governed lifecycle labels and confidence', () => {
+  assert.equal(candidateStatusLabel('pending'), '待审核')
+  assert.equal(candidateStatusLabel('merged'), '已合并')
+  assert.equal(candidateStatusLabel('rolled_back'), '已回滚')
+  assert.equal(suggestionSourceLabel('rule'), '确定性规则')
+  assert.equal(suggestionSourceLabel('ai'), 'AI 建议')
+  assert.equal(confidenceLabel(0.986), '98.6%')
+  assert.equal(confidenceLabel(null), '—')
+})
+
+test('summarizes matched signals from literal explanation data', () => {
+  const summary = matchedSignalSummary([
+    { signal: 'name', matched: true, weight: 0.5 },
+    { signal: 'location', matched: true, weight: 0.15 },
+    { signal: 'model', matched: false, weight: 0.15 }
+  ])
+
+  assert.deepEqual(summary, {
+    matched: 2,
+    total: 3,
+    weight: 0.65
+  })
+})
+
+test('offers only the two candidate assets as canonical choices', () => {
+  const options = canonicalOptions({
+    left_asset_uid: 'asset-left',
+    right_asset_uid: 'asset-right'
+  })
+
+  assert.deepEqual(options, [
+    { text: '左侧资产 · asset-left', value: 'asset-left' },
+    { text: '右侧资产 · asset-right', value: 'asset-right' }
+  ])
+})

+ 119 - 0
migrations/versions/20260729_310_device_entity_resolution.py

@@ -0,0 +1,119 @@
+"""Add governed device entity candidates, merges, and rollbacks."""
+
+from alembic import op
+
+revision = "20260729_310"
+down_revision = "20260729_300"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_entity_match_candidates (
+            uid UUID PRIMARY KEY,
+            left_asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            right_asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            canonical_asset_uid UUID
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            status VARCHAR(20) NOT NULL DEFAULT 'pending'
+                CHECK (
+                    status IN (
+                        'pending','merged','rejected','rolled_back'
+                    )
+                ),
+            suggestion_source VARCHAR(20) NOT NULL
+                CHECK (suggestion_source IN ('rule','ai','manual')),
+            confidence DOUBLE PRECISION NOT NULL
+                CHECK (confidence >= 0 AND confidence <= 1),
+            explanation JSONB NOT NULL DEFAULT '[]'::jsonb,
+            evidence_uids JSONB NOT NULL DEFAULT '[]'::jsonb,
+            model_provider VARCHAR(200),
+            model_name VARCHAR(200),
+            current_version INTEGER NOT NULL DEFAULT 1
+                CHECK (current_version > 0),
+            created_by VARCHAR(100),
+            reviewed_by VARCHAR(100),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (left_asset_uid <> right_asset_uid)
+        );
+        CREATE UNIQUE INDEX uq_device_entity_open_pair
+            ON public.device_entity_match_candidates(
+                left_asset_uid, right_asset_uid
+            )
+            WHERE status IN ('pending','merged');
+        CREATE INDEX idx_device_entity_candidates_status
+            ON public.device_entity_match_candidates(
+                status, suggestion_source, updated_at DESC
+            );
+
+        CREATE TABLE public.device_entity_match_reviews (
+            uid UUID PRIMARY KEY,
+            candidate_uid UUID NOT NULL
+                REFERENCES public.device_entity_match_candidates(uid)
+                ON DELETE CASCADE,
+            version INTEGER NOT NULL CHECK (version > 0),
+            decision VARCHAR(20) NOT NULL
+                CHECK (decision IN ('approve','reject','auto_approve')),
+            reason VARCHAR(1000) NOT NULL,
+            actor_uid VARCHAR(100) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_entity_reviews_candidate
+            ON public.device_entity_match_reviews(
+                candidate_uid, created_at DESC
+            );
+
+        CREATE TABLE public.device_entity_merge_events (
+            uid UUID PRIMARY KEY,
+            candidate_uid UUID NOT NULL UNIQUE
+                REFERENCES public.device_entity_match_candidates(uid)
+                ON DELETE CASCADE,
+            canonical_asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            member_asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            review_uid UUID NOT NULL
+                REFERENCES public.device_entity_match_reviews(uid)
+                ON DELETE RESTRICT,
+            snapshot JSONB NOT NULL,
+            actor_uid VARCHAR(100) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_entity_merge_canonical
+            ON public.device_entity_merge_events(
+                canonical_asset_uid, created_at DESC
+            );
+        CREATE INDEX idx_device_entity_merge_member
+            ON public.device_entity_merge_events(
+                member_asset_uid, created_at DESC
+            );
+
+        CREATE TABLE public.device_entity_merge_rollbacks (
+            uid UUID PRIMARY KEY,
+            merge_uid UUID NOT NULL UNIQUE
+                REFERENCES public.device_entity_merge_events(uid)
+                ON DELETE CASCADE,
+            candidate_uid UUID NOT NULL
+                REFERENCES public.device_entity_match_candidates(uid)
+                ON DELETE CASCADE,
+            reason VARCHAR(1000) NOT NULL,
+            snapshot JSONB NOT NULL,
+            actor_uid VARCHAR(100) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_entity_rollbacks_candidate
+            ON public.device_entity_merge_rollbacks(
+                candidate_uid, created_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Candidate, review, merge, and rollback evidence is retained.
+    pass

+ 485 - 0
tests/data_research/test_device_entity_resolution.py

@@ -0,0 +1,485 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from datetime import datetime
+
+import pytest
+
+from app.core.data_research.device_assets import (
+    DeviceAssetDetail,
+    DeviceAssetMappingRecord,
+    DeviceAssetRecord,
+)
+
+SOURCE_A = "00000000-0000-0000-0000-000000000101"
+SOURCE_B = "00000000-0000-0000-0000-000000000102"
+LEFT_UID = "00000000-0000-7000-8000-000000000201"
+RIGHT_UID = "00000000-0000-7000-8000-000000000202"
+
+
+def asset(
+    uid,
+    source_uid,
+    source_code,
+    *,
+    name="一号循环泵",
+    asset_type="device",
+    location="动力车间",
+    organization="设备动力部",
+    responsible_person="张工",
+    model="P-100",
+):
+    now = datetime(2026, 7, 29, 9, 0)
+    record = DeviceAssetRecord(
+        uid=uid,
+        asset_type=asset_type,
+        name=name,
+        status="active",
+        current_version=1,
+        content_hash="a" * 64,
+        location=location,
+        organization=organization,
+        responsible_person=responsible_person,
+        attributes={"model": model},
+        created_by="editor-1",
+        updated_by="editor-1",
+        created_at=now,
+        updated_at=now,
+    )
+    mapping = DeviceAssetMappingRecord(
+        uid=f"{uid[:-3]}3{uid[-2:]}",
+        asset_uid=uid,
+        source_uid=source_uid,
+        source_entity="asset.equipment",
+        asset_type=asset_type,
+        source_code=source_code,
+        source_updated_at=now,
+        first_seen_at=now,
+        last_seen_at=now,
+    )
+    return DeviceAssetDetail(asset=record, mappings=(mapping,))
+
+
+class MemoryResolutionRepository:
+    def __init__(self, assets=()):
+        self.assets = {item.asset.uid: item for item in assets}
+        self.candidates = {}
+        self.reviews = []
+        self.merge_records = []
+        self.rollback_records = []
+
+    def list_matchable_assets(self, asset_type, *, limit):
+        return [
+            item
+            for item in self.assets.values()
+            if item.asset.asset_type == asset_type
+        ][:limit]
+
+    def get_asset_detail(self, uid):
+        return self.assets.get(uid)
+
+    def find_open_pair(self, left_uid, right_uid):
+        return next(
+            (
+                item
+                for item in self.candidates.values()
+                if item.left_asset_uid == left_uid
+                and item.right_asset_uid == right_uid
+                and item.status in {"pending", "merged"}
+            ),
+            None,
+        )
+
+    def create_candidate(self, record):
+        self.candidates[record.uid] = record
+        return record
+
+    def search_candidates(self, filters, *, page, page_size):
+        records = list(self.candidates.values())
+        for name in ("status", "suggestion_source"):
+            if filters.get(name):
+                records = [
+                    item
+                    for item in records
+                    if getattr(item, name) == filters[name]
+                ]
+        start = (page - 1) * page_size
+        return records[start : start + page_size], len(records)
+
+    def get_candidate(self, uid, *, for_update=False):
+        del for_update
+        return self.candidates.get(uid)
+
+    def update_candidate(self, record):
+        self.candidates[record.uid] = record
+        return record
+
+    def append_review(self, record):
+        self.reviews.append(record)
+        return record
+
+    def active_merge_for_member(self, asset_uid):
+        rolled_back = {item.merge_uid for item in self.rollback_records}
+        return next(
+            (
+                item
+                for item in self.merge_records
+                if item.member_asset_uid == asset_uid
+                and item.uid not in rolled_back
+            ),
+            None,
+        )
+
+    def create_merge(self, record):
+        self.merge_records.append(record)
+        return record
+
+    def get_merge(self, uid, *, for_update=False):
+        del for_update
+        return next(
+            (item for item in self.merge_records if item.uid == uid),
+            None,
+        )
+
+    def create_rollback(self, record):
+        self.rollback_records.append(record)
+        return record
+
+    def list_reviews(self, candidate_uid):
+        return [
+            item for item in self.reviews if item.candidate_uid == candidate_uid
+        ]
+
+    def list_merges(self, candidate_uid):
+        return [
+            item
+            for item in self.merge_records
+            if item.candidate_uid == candidate_uid
+        ]
+
+    def list_rollbacks(self, merge_uid):
+        return [
+            item
+            for item in self.rollback_records
+            if item.merge_uid == merge_uid
+        ]
+
+
+def service(repository, *, authorized=True, auto_merge_enabled=False):
+    from app.core.data_research.device_entity_resolution import (
+        DeviceEntityForbidden,
+        DeviceEntityResolutionService,
+    )
+
+    ids = iter(
+        f"00000000-0000-7000-8000-0000000003{index:02d}"
+        for index in range(1, 40)
+    )
+    clock = iter(
+        datetime(2026, 7, 29, 10, minute)
+        for minute in range(1, 40)
+    )
+
+    def authorize(actor_uid):
+        if not authorized:
+            raise DeviceEntityForbidden(
+                f"{actor_uid} is not the accountable asset manager"
+            )
+
+    return DeviceEntityResolutionService(
+        repository,
+        review_authorizer=authorize,
+        uid_factory=ids.__next__,
+        now_factory=clock.__next__,
+        auto_merge_enabled=auto_merge_enabled,
+    )
+
+
+def test_score_is_explainable_and_uses_hand_derived_weights():
+    from app.core.data_research.device_entity_resolution import (
+        score_device_pair,
+    )
+
+    left = asset(LEFT_UID, SOURCE_A, "EQ-001")
+    right = asset(
+        RIGHT_UID,
+        SOURCE_B,
+        "设备-001",
+        name=" 一号 循环泵 ",
+        responsible_person="李工",
+    )
+
+    score = score_device_pair(left, right)
+
+    assert score.confidence == pytest.approx(0.90)
+    assert [item["signal"] for item in score.explanation] == [
+        "name",
+        "location",
+        "organization",
+        "responsible_person",
+        "model",
+        "source_code",
+    ]
+    assert score.explanation[0]["matched"] is True
+    assert score.explanation[3]["matched"] is False
+    assert score.explanation[5]["matched"] is False
+
+
+def test_generation_is_cross_source_bounded_and_idempotent_for_open_pair():
+    left = asset(LEFT_UID, SOURCE_A, "EQ-001")
+    right = asset(RIGHT_UID, SOURCE_B, "EQ-001")
+    same_source = asset(
+        "00000000-0000-7000-8000-000000000203",
+        SOURCE_A,
+        "EQ-003",
+        name="三号空压机",
+        location="空压站",
+        organization="公用工程部",
+        responsible_person="王工",
+        model="AC-300",
+    )
+    repository = MemoryResolutionRepository((left, right, same_source))
+    resolution = service(repository)
+
+    first = resolution.generate(
+        {"asset_type": "device", "threshold": 0.7, "limit": 20},
+        actor_uid="editor-1",
+    )
+    second = resolution.generate(
+        {"asset_type": "device", "threshold": 0.7, "limit": 20},
+        actor_uid="editor-1",
+    )
+
+    assert first.created_count == 1
+    assert first.existing_count == 0
+    assert second.created_count == 0
+    assert second.existing_count == 1
+    assert first.records[0].left_asset_uid == LEFT_UID
+    assert first.records[0].right_asset_uid == RIGHT_UID
+    assert first.records[0].evidence_uids == (
+        LEFT_UID,
+        left.mappings[0].uid,
+        RIGHT_UID,
+        right.mappings[0].uid,
+    )
+
+
+def test_ai_candidate_requires_provider_model_evidence_and_never_auto_merges():
+    from app.core.data_research.device_entity_resolution import (
+        DeviceEntityInvalid,
+    )
+
+    repository = MemoryResolutionRepository(
+        (
+            asset(LEFT_UID, SOURCE_A, "EQ-001"),
+            asset(RIGHT_UID, SOURCE_B, "DEVICE-001"),
+        )
+    )
+    resolution = service(
+        repository,
+        authorized=True,
+        auto_merge_enabled=True,
+    )
+
+    with pytest.raises(DeviceEntityInvalid, match="model_provider"):
+        resolution.submit_ai_candidate(
+            {
+                "left_asset_uid": LEFT_UID,
+                "right_asset_uid": RIGHT_UID,
+                "confidence": 0.999,
+                "model_name": "entity-match-v1",
+                "evidence_uids": ["evidence-1"],
+                "explanation": "同一台设备",
+            },
+            actor_uid="editor-1",
+        )
+
+    candidate = resolution.submit_ai_candidate(
+        {
+            "left_asset_uid": LEFT_UID,
+            "right_asset_uid": RIGHT_UID,
+            "confidence": 0.999,
+            "model_provider": "governed-provider",
+            "model_name": "entity-match-v1",
+            "evidence_uids": ["evidence-1"],
+            "explanation": "同一台设备",
+        },
+        actor_uid="editor-1",
+    )
+
+    assert candidate.status == "pending"
+    assert candidate.suggestion_source == "ai"
+    assert repository.merge_records == []
+
+
+def test_review_requires_accountable_manager_and_appends_merge_evidence():
+    repository = MemoryResolutionRepository(
+        (
+            asset(LEFT_UID, SOURCE_A, "EQ-001"),
+            asset(RIGHT_UID, SOURCE_B, "EQ-001"),
+        )
+    )
+    blocked = service(repository, authorized=False)
+    candidate = blocked.generate(
+        {"asset_type": "device", "threshold": 0.7},
+        actor_uid="editor-1",
+    ).records[0]
+
+    from app.core.data_research.device_entity_resolution import (
+        DeviceEntityForbidden,
+    )
+
+    with pytest.raises(DeviceEntityForbidden, match="accountable"):
+        blocked.review(
+            candidate.uid,
+            {
+                "decision": "approve",
+                "canonical_asset_uid": LEFT_UID,
+                "expected_version": 1,
+                "reason": "跨系统编码和型号一致",
+            },
+            actor_uid="admin-1",
+        )
+
+    approved, review, merge = service(repository).review(
+        candidate.uid,
+        {
+            "decision": "approve",
+            "canonical_asset_uid": LEFT_UID,
+            "expected_version": 1,
+            "reason": "跨系统编码和型号一致",
+        },
+        actor_uid="admin-1",
+    )
+
+    assert approved.status == "merged"
+    assert approved.canonical_asset_uid == LEFT_UID
+    assert review.decision == "approve"
+    assert merge.canonical_asset_uid == LEFT_UID
+    assert merge.member_asset_uid == RIGHT_UID
+    assert merge.snapshot["member"]["uid"] == RIGHT_UID
+    assert len(repository.reviews) == 1
+    assert len(repository.merge_records) == 1
+
+
+def test_rollback_is_append_only_and_restores_candidate_state():
+    repository = MemoryResolutionRepository(
+        (
+            asset(LEFT_UID, SOURCE_A, "EQ-001"),
+            asset(RIGHT_UID, SOURCE_B, "EQ-001"),
+        )
+    )
+    resolution = service(repository)
+    candidate = resolution.generate(
+        {"asset_type": "device", "threshold": 0.7},
+        actor_uid="editor-1",
+    ).records[0]
+    _candidate, _review, merge = resolution.review(
+        candidate.uid,
+        {
+            "decision": "approve",
+            "canonical_asset_uid": LEFT_UID,
+            "expected_version": 1,
+            "reason": "匹配证据充分",
+        },
+        actor_uid="admin-1",
+    )
+
+    rolled_back, rollback = resolution.rollback(
+        merge.uid,
+        {
+            "expected_version": 2,
+            "reason": "现场确认不是同一台设备",
+        },
+        actor_uid="admin-1",
+    )
+
+    assert rolled_back.status == "rolled_back"
+    assert rolled_back.current_version == 3
+    assert rollback.merge_uid == merge.uid
+    assert rollback.snapshot["merge"]["member_asset_uid"] == RIGHT_UID
+    assert len(repository.merge_records) == 1
+    assert len(repository.rollback_records) == 1
+    assert repository.active_merge_for_member(RIGHT_UID) is None
+
+
+def test_rule_auto_merge_is_default_off_and_requires_strict_threshold():
+    left = asset(LEFT_UID, SOURCE_A, "EQ-001")
+    right = asset(RIGHT_UID, SOURCE_B, "EQ-001")
+
+    default_repository = MemoryResolutionRepository((left, right))
+    default_result = service(default_repository).generate(
+        {"asset_type": "device", "threshold": 0.7},
+        actor_uid="admin-1",
+    )
+    assert default_result.records[0].status == "pending"
+
+    enabled_repository = MemoryResolutionRepository((left, right))
+    enabled_result = service(
+        enabled_repository,
+        auto_merge_enabled=True,
+    ).generate(
+        {"asset_type": "device", "threshold": 0.7},
+        actor_uid="admin-1",
+    )
+    assert enabled_result.records[0].status == "merged"
+    assert enabled_repository.reviews[0].decision == "auto_approve"
+    assert enabled_repository.merge_records[0].member_asset_uid == RIGHT_UID
+
+
+def test_review_rejects_stale_version_and_active_member_conflict():
+    from app.core.data_research.device_entity_resolution import (
+        DeviceEntityConflict,
+    )
+
+    repository = MemoryResolutionRepository(
+        (
+            asset(LEFT_UID, SOURCE_A, "EQ-001"),
+            asset(RIGHT_UID, SOURCE_B, "EQ-001"),
+        )
+    )
+    resolution = service(repository)
+    candidate = resolution.generate(
+        {"asset_type": "device", "threshold": 0.7},
+        actor_uid="editor-1",
+    ).records[0]
+
+    with pytest.raises(DeviceEntityConflict, match="version"):
+        resolution.review(
+            candidate.uid,
+            {
+                "decision": "reject",
+                "expected_version": 2,
+                "reason": "版本已变化",
+            },
+            actor_uid="admin-1",
+        )
+
+    repository.candidates[candidate.uid] = replace(
+        candidate,
+        current_version=1,
+    )
+    resolution.review(
+        candidate.uid,
+        {
+            "decision": "approve",
+            "canonical_asset_uid": LEFT_UID,
+            "expected_version": 1,
+            "reason": "证据充分",
+        },
+        actor_uid="admin-1",
+    )
+
+    with pytest.raises(DeviceEntityConflict, match="active merge"):
+        resolution.submit_ai_candidate(
+            {
+                "left_asset_uid": LEFT_UID,
+                "right_asset_uid": RIGHT_UID,
+                "confidence": 0.9,
+                "model_provider": "governed-provider",
+                "model_name": "entity-match-v1",
+                "evidence_uids": ["evidence-1"],
+                "explanation": "重复候选",
+            },
+            actor_uid="editor-1",
+        )

+ 310 - 0
tests/data_research/test_device_entity_resolution_api.py

@@ -0,0 +1,310 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from datetime import UTC, datetime
+
+import pytest
+
+CANDIDATE_UID = "01900000-0000-7000-8000-000000000701"
+LEFT_UID = "01900000-0000-7000-8000-000000000702"
+RIGHT_UID = "01900000-0000-7000-8000-000000000703"
+MERGE_UID = "01900000-0000-7000-8000-000000000704"
+
+
+class FakeDeviceEntityResolutionService:
+    def __init__(self):
+        from app.core.data_research.device_entity_resolution import (
+            DeviceEntityCandidateRecord,
+            DeviceEntityGenerationResult,
+            DeviceEntityMergeRecord,
+            DeviceEntityReviewRecord,
+            DeviceEntityRollbackRecord,
+        )
+
+        now = datetime(2026, 7, 29, 16, 0, tzinfo=UTC)
+        self.candidate = DeviceEntityCandidateRecord(
+            uid=CANDIDATE_UID,
+            left_asset_uid=LEFT_UID,
+            right_asset_uid=RIGHT_UID,
+            canonical_asset_uid=None,
+            status="pending",
+            suggestion_source="rule",
+            confidence=0.99,
+            explanation=(
+                {
+                    "signal": "name",
+                    "matched": True,
+                    "weight": 0.5,
+                    "left": "一号循环泵",
+                    "right": "一号循环泵",
+                },
+            ),
+            evidence_uids=("evidence-1", "evidence-2"),
+            model_provider=None,
+            model_name=None,
+            current_version=1,
+            created_by="editor-1",
+            reviewed_by=None,
+            created_at=now,
+            updated_at=now,
+        )
+        self.review_record = DeviceEntityReviewRecord(
+            uid="01900000-0000-7000-8000-000000000705",
+            candidate_uid=CANDIDATE_UID,
+            version=1,
+            decision="approve",
+            reason="企业设备管理员确认",
+            actor_uid="admin-1",
+            created_at=now,
+        )
+        self.merge_record = DeviceEntityMergeRecord(
+            uid=MERGE_UID,
+            candidate_uid=CANDIDATE_UID,
+            canonical_asset_uid=LEFT_UID,
+            member_asset_uid=RIGHT_UID,
+            review_uid=self.review_record.uid,
+            snapshot={"canonical": {"uid": LEFT_UID}, "member": {"uid": RIGHT_UID}},
+            actor_uid="admin-1",
+            created_at=now,
+        )
+        self.rollback_record = DeviceEntityRollbackRecord(
+            uid="01900000-0000-7000-8000-000000000706",
+            merge_uid=MERGE_UID,
+            candidate_uid=CANDIDATE_UID,
+            reason="现场确认不是同一台设备",
+            snapshot={"merge": {"uid": MERGE_UID}},
+            actor_uid="admin-1",
+            created_at=now,
+        )
+        self.generation = DeviceEntityGenerationResult(
+            records=(self.candidate,),
+            created_count=1,
+            existing_count=0,
+            evaluated_pair_count=1,
+            auto_merged_count=0,
+        )
+        self.actions = []
+
+    def search(self, filters, *, page, page_size):
+        self.actions.append(("search", filters, page, page_size))
+        return [self.candidate], 1
+
+    def get(self, candidate_uid):
+        self.actions.append(("get", candidate_uid))
+        return self.candidate
+
+    def generate(self, payload, actor_uid):
+        self.actions.append(("generate", payload, actor_uid))
+        return self.generation
+
+    def submit_ai_candidate(self, payload, actor_uid):
+        self.actions.append(("submit_ai", payload, actor_uid))
+        return replace(
+            self.candidate,
+            suggestion_source="ai",
+            model_provider=payload.get("model_provider"),
+            model_name=payload.get("model_name"),
+        )
+
+    def review(self, candidate_uid, payload, actor_uid):
+        self.actions.append(("review", candidate_uid, payload, actor_uid))
+        return (
+            replace(
+                self.candidate,
+                status="merged",
+                canonical_asset_uid=LEFT_UID,
+                current_version=2,
+            ),
+            self.review_record,
+            self.merge_record,
+        )
+
+    def rollback(self, merge_uid, payload, actor_uid):
+        self.actions.append(("rollback", merge_uid, payload, actor_uid))
+        return (
+            replace(
+                self.candidate,
+                status="rolled_back",
+                canonical_asset_uid=LEFT_UID,
+                current_version=3,
+            ),
+            self.rollback_record,
+        )
+
+    def reviews(self, candidate_uid):
+        self.actions.append(("reviews", candidate_uid))
+        return [self.review_record]
+
+    def merges(self, candidate_uid):
+        self.actions.append(("merges", candidate_uid))
+        return [self.merge_record]
+
+    def rollbacks(self, merge_uid):
+        self.actions.append(("rollbacks", merge_uid))
+        return [self.rollback_record]
+
+
+@pytest.fixture()
+def client(monkeypatch):
+    from flask import request
+
+    from app import create_app
+    from app.api.data_development import routes
+    from app.core.system import permissions
+
+    service = FakeDeviceEntityResolutionService()
+
+    def identity():
+        role = request.headers.get(
+            "Authorization",
+            "",
+        ).removeprefix("Bearer ")
+        if role not in {"viewer", "editor", "admin"}:
+            return None
+        return {"id": f"{role}-1", "roles": [role]}
+
+    monkeypatch.setattr(permissions, "authenticate_request", identity)
+    monkeypatch.setattr(
+        routes,
+        "get_device_entity_resolution_service",
+        lambda: service,
+        raising=False,
+    )
+    app = create_app()
+    app.config.update(
+        TESTING=True,
+        DEVICE_ENTITY_AUTO_MERGE_ENABLED=False,
+    )
+    return app.test_client(), service
+
+
+def test_viewer_reads_candidates_and_evidence_but_cannot_generate(client):
+    http, service = client
+    headers = {"Authorization": "Bearer viewer"}
+
+    listed = http.get(
+        "/api/development/v1/device-entities/candidates"
+        "?status=pending&suggestion_source=rule&page=1&page_size=20",
+        headers=headers,
+    )
+    detail = http.get(
+        f"/api/development/v1/device-entities/candidates/{CANDIDATE_UID}",
+        headers=headers,
+    )
+    reviews = http.get(
+        f"/api/development/v1/device-entities/candidates/{CANDIDATE_UID}/reviews",
+        headers=headers,
+    )
+    merges = http.get(
+        f"/api/development/v1/device-entities/candidates/{CANDIDATE_UID}/merges",
+        headers=headers,
+    )
+    rollbacks = http.get(
+        f"/api/development/v1/device-entities/merges/{MERGE_UID}/rollbacks",
+        headers=headers,
+    )
+    denied = http.post(
+        "/api/development/v1/device-entities/candidates/generate",
+        headers=headers,
+        json={},
+    )
+
+    assert listed.status_code == 200
+    assert listed.get_json()["data"]["auto_merge_enabled"] is False
+    assert listed.get_json()["data"]["records"][0]["confidence"] == 0.99
+    assert detail.get_json()["data"]["evidence_uids"] == [
+        "evidence-1",
+        "evidence-2",
+    ]
+    assert reviews.get_json()["data"]["records"][0]["decision"] == "approve"
+    assert merges.get_json()["data"]["records"][0]["uid"] == MERGE_UID
+    assert rollbacks.get_json()["data"]["records"][0]["reason"].startswith(
+        "现场确认"
+    )
+    assert denied.status_code == 403
+    assert service.actions[0] == (
+        "search",
+        {"status": "pending", "suggestion_source": "rule"},
+        "1",
+        "20",
+    )
+
+
+def test_editor_generates_and_submits_ai_candidate_but_cannot_review(client):
+    http, service = client
+    headers = {"Authorization": "Bearer editor"}
+    generated = http.post(
+        "/api/development/v1/device-entities/candidates/generate",
+        headers=headers,
+        json={"asset_type": "device", "threshold": 0.8},
+    )
+    submitted = http.post(
+        "/api/development/v1/device-entities/candidates",
+        headers=headers,
+        json={
+            "left_asset_uid": LEFT_UID,
+            "right_asset_uid": RIGHT_UID,
+            "confidence": 0.91,
+            "model_provider": "governed-provider",
+            "model_name": "entity-match-v1",
+            "evidence_uids": ["evidence-1"],
+            "explanation": "同一台设备",
+        },
+    )
+    denied = http.post(
+        f"/api/development/v1/device-entities/candidates/{CANDIDATE_UID}/review",
+        headers=headers,
+        json={
+            "decision": "approve",
+            "canonical_asset_uid": LEFT_UID,
+            "expected_version": 1,
+            "reason": "证据充分",
+        },
+    )
+
+    assert generated.status_code == 201
+    assert generated.get_json()["data"]["created_count"] == 1
+    assert submitted.status_code == 201
+    assert submitted.get_json()["data"]["suggestion_source"] == "ai"
+    assert denied.status_code == 403
+    assert service.actions[0][2] == "editor-1"
+    assert service.actions[1][2] == "editor-1"
+
+
+def test_admin_review_and_rollback_return_immutable_evidence(client):
+    http, service = client
+    headers = {"Authorization": "Bearer admin"}
+
+    reviewed = http.post(
+        f"/api/development/v1/device-entities/candidates/{CANDIDATE_UID}/review",
+        headers=headers,
+        json={
+            "decision": "approve",
+            "canonical_asset_uid": LEFT_UID,
+            "expected_version": 1,
+            "reason": "企业设备管理员确认",
+        },
+    )
+    rolled_back = http.post(
+        f"/api/development/v1/device-entities/merges/{MERGE_UID}/rollback",
+        headers=headers,
+        json={
+            "expected_version": 2,
+            "reason": "现场确认不是同一台设备",
+        },
+    )
+
+    assert reviewed.status_code == 200
+    assert reviewed.get_json()["data"]["candidate"]["status"] == "merged"
+    assert reviewed.get_json()["data"]["review"]["actor_uid"] == "admin-1"
+    assert reviewed.get_json()["data"]["merge"]["member_asset_uid"] == (
+        RIGHT_UID
+    )
+    assert rolled_back.status_code == 200
+    assert rolled_back.get_json()["data"]["candidate"]["status"] == (
+        "rolled_back"
+    )
+    assert rolled_back.get_json()["data"]["rollback"]["merge_uid"] == (
+        MERGE_UID
+    )
+    assert service.actions[-1][3] == "admin-1"

+ 181 - 0
tests/integration/test_device_entity_resolution_postgres.py

@@ -0,0 +1,181 @@
+from __future__ import annotations
+
+import os
+import uuid
+
+import pytest
+
+pytestmark = pytest.mark.integration
+
+
+def test_entity_resolution_persists_review_merge_and_rollback(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.device_asset_repository import (
+        SqlAlchemyDeviceAssetRepository,
+    )
+    from app.core.data_research.device_assets import DeviceAssetService
+    from app.core.data_research.device_entity_repository import (
+        SqlAlchemyDeviceEntityResolutionRepository,
+    )
+    from app.core.data_research.device_entity_resolution import (
+        DeviceEntityResolutionService,
+    )
+    from app.models.data_research import (
+        DeviceAsset,
+        DeviceAssetSourceMapping,
+        DeviceAssetVersion,
+        DeviceEntityMatchCandidate,
+        DeviceEntityMatchReview,
+        DeviceEntityMergeEvent,
+        DeviceEntityMergeRollback,
+        IngestionSource,
+    )
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    source_uids = [str(uuid.uuid4()), str(uuid.uuid4())]
+    asset_uids = []
+    candidate_uid = None
+    merge_uid = None
+    try:
+        with app.app_context():
+            for index, source_uid in enumerate(source_uids, start=1):
+                db.session.add(
+                    IngestionSource(
+                        uid=source_uid,
+                        source_type="database",
+                        name=f"WP06 实体匹配测试源 {index}",
+                        config={
+                            "database_type": "postgresql",
+                            "database": f"wp06_{index}",
+                            "schema": "asset",
+                        },
+                        permission_scope={},
+                        status="active",
+                        created_by="integration-test",
+                    )
+                )
+            db.session.commit()
+
+            assets = DeviceAssetService(
+                SqlAlchemyDeviceAssetRepository(db.session),
+                commit=db.session.commit,
+                rollback=db.session.rollback,
+            )
+            for source_uid, source_code in zip(
+                source_uids,
+                ("EQ-WP06-001", "EQ-WP06-001"),
+                strict=True,
+            ):
+                result = assets.import_records(
+                    {
+                        "source_uid": source_uid,
+                        "source_entity": "asset.equipment",
+                        "records": [
+                            {
+                                "asset_type": "device",
+                                "source_code": source_code,
+                                "name": "WP06 循环水泵",
+                                "location": "动力车间",
+                                "organization": "设备动力部",
+                                "responsible_person": "张工",
+                                "attributes": {"model": "P-WP06"},
+                            }
+                        ],
+                    },
+                    actor_uid="integration-test",
+                )
+                asset_uids.append(result.items[0].asset.uid)
+
+            repository = SqlAlchemyDeviceEntityResolutionRepository(
+                db.session
+            )
+            service = DeviceEntityResolutionService(
+                repository,
+                review_authorizer=lambda _actor_uid: None,
+                commit=db.session.commit,
+                rollback=db.session.rollback,
+            )
+            generated = service.generate(
+                {"asset_type": "device", "threshold": 0.98, "limit": 100},
+                actor_uid="editor-test",
+            )
+            owned = [
+                item
+                for item in generated.records
+                if {item.left_asset_uid, item.right_asset_uid}
+                == set(asset_uids)
+            ]
+            assert len(owned) == 1
+            candidate = owned[0]
+            candidate_uid = candidate.uid
+
+            merged, review, merge = service.review(
+                candidate.uid,
+                {
+                    "decision": "approve",
+                    "canonical_asset_uid": asset_uids[0],
+                    "expected_version": 1,
+                    "reason": "集成测试证据一致",
+                },
+                actor_uid="admin-test",
+            )
+            merge_uid = merge.uid
+
+            assert merged.status == "merged"
+            assert review.decision == "approve"
+            assert repository.active_merge_for_member(asset_uids[1]).uid == (
+                merge.uid
+            )
+            assert merge.snapshot["canonical"]["uid"] == asset_uids[0]
+            assert len(repository.list_reviews(candidate.uid)) == 1
+
+            rolled_back, rollback = service.rollback(
+                merge.uid,
+                {
+                    "expected_version": 2,
+                    "reason": "集成测试回滚",
+                },
+                actor_uid="admin-test",
+            )
+            assert rolled_back.status == "rolled_back"
+            assert rollback.snapshot["merge"]["member_asset_uid"] == (
+                asset_uids[1]
+            )
+            assert repository.active_merge_for_member(asset_uids[1]) is None
+            assert len(repository.list_rollbacks(merge.uid)) == 1
+    finally:
+        with app.app_context():
+            if merge_uid:
+                db.session.query(DeviceEntityMergeRollback).filter_by(
+                    merge_uid=merge_uid
+                ).delete(synchronize_session=False)
+            if candidate_uid:
+                db.session.query(DeviceEntityMergeEvent).filter_by(
+                    candidate_uid=candidate_uid
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceEntityMatchReview).filter_by(
+                    candidate_uid=candidate_uid
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceEntityMatchCandidate).filter_by(
+                    uid=candidate_uid
+                ).delete(synchronize_session=False)
+            if asset_uids:
+                db.session.query(DeviceAssetVersion).filter(
+                    DeviceAssetVersion.asset_uid.in_(asset_uids)
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceAssetSourceMapping).filter(
+                    DeviceAssetSourceMapping.asset_uid.in_(asset_uids)
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceAsset).filter(
+                    DeviceAsset.uid.in_(asset_uids)
+                ).delete(synchronize_session=False)
+            db.session.query(IngestionSource).filter(
+                IngestionSource.uid.in_(source_uids)
+            ).delete(synchronize_session=False)
+            db.session.commit()

+ 28 - 0
tests/test_database_migrations.py

@@ -152,6 +152,34 @@ def test_device_semantics_migration_is_versioned_reviewed_and_traceable():
     assert "DROP TABLE" not in migration.upper()
 
 
+def test_device_entity_resolution_migration_is_non_destructive_and_reversible():
+    migration = (
+        ROOT
+        / "migrations"
+        / "versions"
+        / "20260729_310_device_entity_resolution.py"
+    ).read_text(encoding="utf-8")
+
+    assert 'revision = "20260729_310"' in migration
+    assert 'down_revision = "20260729_300"' in migration
+    for table in (
+        "device_entity_match_candidates",
+        "device_entity_match_reviews",
+        "device_entity_merge_events",
+        "device_entity_merge_rollbacks",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+    assert "explanation JSONB NOT NULL" in migration
+    assert "evidence_uids JSONB NOT NULL" in migration
+    assert "decision IN ('approve','reject','auto_approve')" in migration
+    assert "canonical_asset_uid" in migration
+    assert "member_asset_uid" in migration
+    assert "snapshot JSONB NOT NULL" in migration
+    assert "DROP TABLE" not in migration.upper()
+    assert "DELETE FROM public.device_assets" not in migration
+    assert "UPDATE public.device_asset_source_mappings" not in migration
+
+
 def test_data_element_migration_adds_versioned_governance_tables():
     migration = (
         ROOT

+ 17 - 0
tests/test_permission_matrix.py

@@ -23,6 +23,9 @@ def test_fixed_role_permission_matrix_is_monotonic():
     assert "device-semantics:edit" in editor
     assert "device-semantics:review" not in editor
     assert "device-semantics:review" in admin
+    assert "device-entities:edit" in editor
+    assert "device-entities:review" not in editor
+    assert "device-entities:review" in admin
 
 
 def test_data_development_paths_have_specific_write_policies():
@@ -78,6 +81,20 @@ def test_data_development_paths_have_specific_write_policies():
         "/api/development/v1/device-semantics/codes/code-1/review",
         "POST",
     ) == ("device-semantics:review",)
+    assert permission_for_request(
+        "/api/development/v1/device-entities/candidates", "GET"
+    ) == ("governance:read",)
+    assert permission_for_request(
+        "/api/development/v1/device-entities/candidates/generate", "POST"
+    ) == ("device-entities:edit",)
+    assert permission_for_request(
+        "/api/development/v1/device-entities/candidates/candidate-1/review",
+        "POST",
+    ) == ("device-entities:review",)
+    assert permission_for_request(
+        "/api/development/v1/device-entities/merges/merge-1/rollback",
+        "POST",
+    ) == ("device-entities:review",)
 
 
 def test_business_domain_read_endpoints_are_available_to_viewers():