Files
ai-ticket-platform/backend/apps/knowledge/governance.py
2026-07-27 19:01:19 +08:00

482 lines
15 KiB
Python

"""知识、排查方案和解决方案的统一版本治理服务。
发布、下线和回滚均保留不可变版本及审计记录,索引更新只在数据库事务提交后触发。
"""
from copy import deepcopy
from django.db import transaction
from django.utils import timezone
from apps.audit.models import OperationAuditLog
from apps.knowledge.models import (
GovernedAsset,
GovernedAssetType,
GovernedAssetVersion,
GovernedAssetVersionStatus,
KnowledgeItem,
TroubleshootingPlan,
)
from apps.solutions.models import Solution
class AssetGovernanceError(ValueError):
pass
ASSET_MODELS = {
GovernedAssetType.KNOWLEDGE_ITEM: KnowledgeItem,
GovernedAssetType.TROUBLESHOOTING_PLAN: TroubleshootingPlan,
GovernedAssetType.SOLUTION: Solution,
}
ASSET_FIELDS = {
GovernedAssetType.KNOWLEDGE_ITEM: (
"knowledge_base_id",
"title",
"problem",
"applicable_scope",
"category_code",
"related_system",
"environment_scope",
"error_code",
"root_cause",
"troubleshooting_steps",
"solution",
"risk_note",
"source_type",
"source_ticket_id",
),
GovernedAssetType.TROUBLESHOOTING_PLAN: (
"knowledge_base_id",
"title",
"problem_pattern",
"applicable_scope",
"category_code",
"related_system",
"environment_scope",
"decision_rules",
"troubleshooting_steps",
"risk_note",
"source_ticket_id",
),
GovernedAssetType.SOLUTION: (
"title",
"problem_signature",
"related_system",
"environment_scope",
"category_id",
"root_cause",
"solution",
"risk_note",
"allow_auto_resolve",
"requires_human_action",
"requires_external_confirmation",
"requires_production_change",
),
}
REQUIRED_FIELDS = {
GovernedAssetType.KNOWLEDGE_ITEM: (
"knowledge_base_id",
"title",
"problem",
"solution",
),
GovernedAssetType.TROUBLESHOOTING_PLAN: (
"knowledge_base_id",
"title",
"problem_pattern",
"troubleshooting_steps",
),
GovernedAssetType.SOLUTION: (
"title",
"problem_signature",
"root_cause",
"solution",
),
}
def snapshot_asset(asset_type: str, instance) -> dict:
try:
fields = ASSET_FIELDS[asset_type]
except KeyError as error:
raise AssetGovernanceError("不支持的知识资产类型") from error
return {field: deepcopy(getattr(instance, field)) for field in fields}
def validate_asset_content(asset_type: str, content: dict) -> dict:
if not isinstance(content, dict):
raise AssetGovernanceError("资产版本内容必须是对象")
allowed = set(ASSET_FIELDS.get(asset_type, ()))
if not allowed:
raise AssetGovernanceError("不支持的知识资产类型")
unknown = sorted(set(content) - allowed)
if unknown:
raise AssetGovernanceError(f"资产版本包含未知字段:{', '.join(unknown)}")
missing = [
field
for field in REQUIRED_FIELDS[asset_type]
if content.get(field) in {None, ""}
]
if missing:
raise AssetGovernanceError(f"资产版本缺少必填字段:{', '.join(missing)}")
return {field: deepcopy(content.get(field)) for field in ASSET_FIELDS[asset_type]}
@transaction.atomic
def ensure_governed_asset(asset_type: str, instance, *, actor=None) -> GovernedAsset:
if asset_type not in ASSET_MODELS:
raise AssetGovernanceError("不支持的知识资产类型")
asset, _ = GovernedAsset.objects.select_for_update().get_or_create(
asset_type=asset_type,
object_id=instance.id,
defaults={"created_by": actor or getattr(instance, "created_by", None)},
)
if not asset.versions.exists():
GovernedAssetVersion.objects.create(
asset=asset,
version=1,
status=GovernedAssetVersionStatus.DRAFT,
content=snapshot_asset(asset_type, instance),
created_by=actor or getattr(instance, "created_by", None),
)
return asset
def get_asset_instance(asset: GovernedAsset):
model = ASSET_MODELS.get(asset.asset_type)
if model is None:
raise AssetGovernanceError("不支持的知识资产类型")
instance = model.objects.filter(id=asset.object_id).first()
if instance is None:
raise AssetGovernanceError("知识资产对应的业务对象不存在")
return instance
def get_editable_version(asset: GovernedAsset) -> GovernedAssetVersion | None:
return asset.versions.filter(
status__in=[
GovernedAssetVersionStatus.DRAFT,
GovernedAssetVersionStatus.REJECTED,
]
).first()
@transaction.atomic
def create_asset_revision(
asset: GovernedAsset,
*,
actor,
source_version: GovernedAssetVersion | None = None,
change_summary: str = "",
) -> GovernedAssetVersion:
"""基于当前发布版本创建唯一可编辑修订;已有草稿时直接复用。"""
asset = GovernedAsset.objects.select_for_update().get(id=asset.id)
editable = get_editable_version(asset)
if editable is not None:
return editable
source = source_version or asset.current_published_version
if source is None:
content = snapshot_asset(asset.asset_type, get_asset_instance(asset))
else:
content = deepcopy(source.content)
latest_version = (
asset.versions.order_by("-version")
.values_list("version", flat=True)
.first()
or 0
)
next_version = latest_version + 1
version = GovernedAssetVersion.objects.create(
asset=asset,
version=next_version,
content=content,
change_summary=change_summary.strip(),
source_version=source,
created_by=actor,
)
_audit(version, actor, "asset_revision_created")
return version
@transaction.atomic
def update_asset_version(
version: GovernedAssetVersion,
content: dict,
*,
actor,
change_summary: str | None = None,
) -> GovernedAssetVersion:
"""执行独立审核、原子切换发布版本,并在提交后刷新检索索引。"""
version = GovernedAssetVersion.objects.select_for_update().select_related("asset").get(
id=version.id
)
if version.status not in {
GovernedAssetVersionStatus.DRAFT,
GovernedAssetVersionStatus.REJECTED,
}:
raise AssetGovernanceError("只有草稿或已驳回版本可以编辑")
version.content = validate_asset_content(version.asset.asset_type, content)
version.status = GovernedAssetVersionStatus.DRAFT
version.review_comment = ""
version.reviewed_by = None
version.reviewed_at = None
if change_summary is not None:
version.change_summary = change_summary.strip()
version.save(
update_fields=[
"content",
"status",
"change_summary",
"review_comment",
"reviewed_by",
"reviewed_at",
"updated_at",
]
)
_audit(version, actor, "asset_draft_updated")
return version
@transaction.atomic
def submit_asset_version(
version: GovernedAssetVersion,
*,
actor,
) -> GovernedAssetVersion:
version = GovernedAssetVersion.objects.select_for_update().select_related("asset").get(
id=version.id
)
if version.status != GovernedAssetVersionStatus.DRAFT:
raise AssetGovernanceError("只有草稿版本可以提交审核")
version.content = validate_asset_content(version.asset.asset_type, version.content)
version.status = GovernedAssetVersionStatus.PENDING_REVIEW
version.submitted_by = actor
version.submitted_at = timezone.now()
version.save(
update_fields=[
"content",
"status",
"submitted_by",
"submitted_at",
"updated_at",
]
)
from apps.workitems.services import sync_asset_review_work_item
sync_asset_review_work_item(version, actor=actor)
_audit(version, actor, "asset_submitted")
return version
def _apply_published_content(version: GovernedAssetVersion):
instance = get_asset_instance(version.asset)
for field, value in version.content.items():
setattr(instance, field, deepcopy(value))
instance.status = GovernedAssetVersionStatus.PUBLISHED
if hasattr(instance, "reviewed_by_id"):
instance.reviewed_by = version.reviewed_by
update_fields = [*version.content.keys(), "status", "updated_at"]
if hasattr(instance, "reviewed_by_id"):
update_fields.append("reviewed_by")
instance.save(update_fields=update_fields)
return instance
@transaction.atomic
def approve_asset_version(
version: GovernedAssetVersion,
*,
reviewer,
comment: str = "",
) -> GovernedAssetVersion:
version = (
GovernedAssetVersion.objects.select_for_update()
.select_related("asset", "submitted_by")
.get(id=version.id)
)
if version.status != GovernedAssetVersionStatus.PENDING_REVIEW:
raise AssetGovernanceError("只有待审核版本可以通过")
if version.submitted_by_id and version.submitted_by_id == reviewer.id:
raise AssetGovernanceError("提交人不能审核自己的资产版本")
now = timezone.now()
previous = version.asset.current_published_version
if previous is not None and previous.id != version.id:
previous.status = GovernedAssetVersionStatus.OFFLINE
previous.offline_at = now
previous.save(update_fields=["status", "offline_at", "updated_at"])
version.status = GovernedAssetVersionStatus.PUBLISHED
version.reviewed_by = reviewer
version.review_comment = comment.strip()
version.reviewed_at = now
version.published_at = now
version.offline_at = None
version.save(
update_fields=[
"status",
"reviewed_by",
"review_comment",
"reviewed_at",
"published_at",
"offline_at",
"updated_at",
]
)
version.asset.current_published_version = version
version.asset.save(update_fields=["current_published_version", "updated_at"])
_apply_published_content(version)
# 避免索引先于业务事务可见,导致检索读取到未提交或已回滚的版本。
transaction.on_commit(
lambda: _dispatch_asset_index(version.asset.asset_type, version.asset.object_id)
)
from apps.workitems.services import sync_asset_review_work_item
sync_asset_review_work_item(version, actor=reviewer)
_audit(version, reviewer, "asset_published")
return version
@transaction.atomic
def reject_asset_version(
version: GovernedAssetVersion,
*,
reviewer,
comment: str,
) -> GovernedAssetVersion:
"""从历史已发布版本复制内容生成新草稿,不直接覆盖当前线上版本。"""
version = (
GovernedAssetVersion.objects.select_for_update()
.select_related("asset", "submitted_by")
.get(id=version.id)
)
if version.status != GovernedAssetVersionStatus.PENDING_REVIEW:
raise AssetGovernanceError("只有待审核版本可以驳回")
if version.submitted_by_id and version.submitted_by_id == reviewer.id:
raise AssetGovernanceError("提交人不能审核自己的资产版本")
if not comment.strip():
raise AssetGovernanceError("驳回时必须填写原因")
version.status = GovernedAssetVersionStatus.REJECTED
version.reviewed_by = reviewer
version.review_comment = comment.strip()
version.reviewed_at = timezone.now()
version.save(
update_fields=[
"status",
"reviewed_by",
"review_comment",
"reviewed_at",
"updated_at",
]
)
from apps.workitems.services import sync_asset_review_work_item
sync_asset_review_work_item(version, actor=reviewer)
_audit(version, reviewer, "asset_rejected")
return version
@transaction.atomic
def offline_asset(asset: GovernedAsset, *, actor, comment: str = ""):
asset = (
GovernedAsset.objects.select_for_update()
.select_related("current_published_version")
.get(id=asset.id)
)
version = asset.current_published_version
if version is None or version.status != GovernedAssetVersionStatus.PUBLISHED:
raise AssetGovernanceError("当前没有可下线的已发布版本")
version.status = GovernedAssetVersionStatus.OFFLINE
version.offline_at = timezone.now()
version.review_comment = comment.strip()
version.save(
update_fields=["status", "offline_at", "review_comment", "updated_at"]
)
instance = get_asset_instance(asset)
instance.status = GovernedAssetVersionStatus.OFFLINE
instance.save(update_fields=["status", "updated_at"])
asset.current_published_version = None
asset.save(update_fields=["current_published_version", "updated_at"])
transaction.on_commit(lambda: _dispatch_asset_index(asset.asset_type, asset.object_id))
_audit(version, actor, "asset_offline")
return version
@transaction.atomic
def rollback_asset(
asset: GovernedAsset,
source_version: GovernedAssetVersion,
*,
actor,
change_summary: str = "",
) -> GovernedAssetVersion:
asset = GovernedAsset.objects.select_for_update().get(id=asset.id)
source_version = GovernedAssetVersion.objects.get(
id=source_version.id,
asset=asset,
)
if source_version.status not in {
GovernedAssetVersionStatus.PUBLISHED,
GovernedAssetVersionStatus.OFFLINE,
}:
raise AssetGovernanceError("只能从历史发布版本创建回滚草稿")
if get_editable_version(asset) is not None:
raise AssetGovernanceError("已有可编辑版本,请先处理后再回滚")
return create_asset_revision(
asset,
actor=actor,
source_version=source_version,
change_summary=change_summary or f"回滚自 v{source_version.version}",
)
def compare_asset_versions(
left: GovernedAssetVersion,
right: GovernedAssetVersion,
) -> dict:
if left.asset_id != right.asset_id:
raise AssetGovernanceError("只能比较同一资产的版本")
fields = sorted(set(left.content) | set(right.content))
return {
"left_version": left.version,
"right_version": right.version,
"changes": [
{
"field": field,
"before": deepcopy(left.content.get(field)),
"after": deepcopy(right.content.get(field)),
}
for field in fields
if left.content.get(field) != right.content.get(field)
],
}
def _dispatch_asset_index(asset_type: str, object_id: int) -> None:
from apps.knowledge.tasks import dispatch_governed_asset_index
dispatch_governed_asset_index(asset_type, object_id)
def _audit(version: GovernedAssetVersion, actor, action: str) -> None:
OperationAuditLog.objects.create(
user=actor,
module="knowledge",
action=action,
target_type=version.asset.asset_type,
target_id=str(version.asset.object_id),
request_snapshot={
"asset_public_id": str(version.asset.public_id),
"version": version.version,
"status": version.status,
"source_version": version.source_version.version
if version.source_version_id
else None,
},
result="success",
)