diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/pom.xml b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/pom.xml
index d6846abc..adec6d04 100644
--- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/pom.xml
+++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/pom.xml
@@ -22,6 +22,11 @@
muse-module-knowledge-api
${revision}
+
+ cn.iocoder.cloud
+ muse-module-events-api
+ ${revision}
+
cn.iocoder.cloud
muse-module-infra-api
diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeEventPublishOutboxService.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeEventPublishOutboxService.java
new file mode 100644
index 00000000..7ec7b92e
--- /dev/null
+++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeEventPublishOutboxService.java
@@ -0,0 +1,22 @@
+package cn.iocoder.muse.module.knowledge.application.muse;
+
+import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceEventDO;
+
+/**
+ * Knowledge source status 发布 outbox 服务。
+ */
+public interface MuseKnowledgeEventPublishOutboxService {
+
+ /**
+ * 为 canonical Knowledge source event 创建 per-owner Events publish outbox。
+ */
+ OwnerFanoutSummary createForSourceEvent(MuseKnowledgeSourceEventDO event);
+
+ record OwnerFanoutSummary(long totalProjectionCount,
+ long validOwnerProjectionCount,
+ long invalidOwnerProjectionCount,
+ long distinctTargetOwnerCount,
+ int queuedOutboxCount) {
+ }
+
+}
diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeEventPublishOutboxServiceImpl.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeEventPublishOutboxServiceImpl.java
new file mode 100644
index 00000000..63304db1
--- /dev/null
+++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeEventPublishOutboxServiceImpl.java
@@ -0,0 +1,192 @@
+package cn.iocoder.muse.module.knowledge.application.muse;
+
+import cn.iocoder.muse.framework.common.util.json.JsonUtils;
+import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeEventPublishOutboxDO;
+import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceBindingProjectionDO;
+import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceEventDO;
+import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeEventPublishOutboxMapper;
+import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeSourceBindingProjectionMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Knowledge source status 发布 outbox 服务实现。
+ */
+@Service
+@Slf4j
+public class MuseKnowledgeEventPublishOutboxServiceImpl implements MuseKnowledgeEventPublishOutboxService {
+
+ static final String STATUS_QUEUED = "queued";
+ static final int DEFAULT_MAX_ATTEMPT = 5;
+
+ private static final String SOURCE_REVISION_NONE = "__none__";
+ private static final String EVENT_TYPE_NOTIFICATION = "notification";
+ private static final String NOTIFICATION_SOURCE_STATUS_CHANGE = "source_status_change";
+ private static final Set PUBLISHABLE_KNOWLEDGE_EVENT_TYPES = Set.of(
+ "version_changed", "policy_changed", "kb_disabled", "kb_enabled", "status_changed");
+
+ @Resource
+ private MuseKnowledgeEventPublishOutboxMapper outboxMapper;
+ @Resource
+ private MuseKnowledgeSourceBindingProjectionMapper sourceBindingProjectionMapper;
+
+ @Override
+ public OwnerFanoutSummary createForSourceEvent(MuseKnowledgeSourceEventDO event) {
+ if (event == null || !StringUtils.hasText(event.getSourceEventId()) || event.getKbId() == null) {
+ return new OwnerFanoutSummary(0L, 0L, 0L, 0L, 0);
+ }
+ String knowledgeEventType = knowledgeEventType(event);
+ MuseKnowledgeSourceBindingProjectionMapper.FanoutOwnerAuditSummary auditSummary =
+ sourceBindingProjectionMapper.selectFanoutOwnerAuditSummaryByLastEventId(event.getKbId(),
+ event.getSourceEventId());
+ if (!PUBLISHABLE_KNOWLEDGE_EVENT_TYPES.contains(knowledgeEventType)) {
+ log.info("Knowledge Events outbox 跳过非首批 source event, tenantId={}, kbId={}, sourceEventId={}, eventType={}",
+ event.getTenantId(), event.getKbId(), event.getSourceEventId(), knowledgeEventType);
+ return ownerFanoutSummary(auditSummary, 0);
+ }
+
+ List targets =
+ sourceBindingProjectionMapper.selectFanoutTargetsByLastEventId(event.getKbId(),
+ event.getSourceEventId());
+ Map> grouped = targets.stream()
+ .collect(Collectors.groupingBy(MuseKnowledgeSourceBindingProjectionDO::getOwnerUserId,
+ LinkedHashMap::new, Collectors.toList()));
+ int queued = 0;
+ for (Map.Entry> entry : grouped.entrySet()) {
+ MuseKnowledgeSourceBindingProjectionDO representative = entry.getValue().getFirst();
+ MuseKnowledgeEventPublishOutboxDO outbox = buildOutbox(event, knowledgeEventType, representative,
+ entry.getValue().size());
+ int inserted = outboxMapper.insertIgnore(outbox);
+ if (inserted > 0) {
+ queued++;
+ }
+ }
+ if (auditSummary.invalidOwnerProjectionCount() > 0) {
+ // 非法 owner 不写 outbox,保留安全计数用于 projection task summary 和排障。
+ log.warn("Knowledge Events outbox 发现非法 owner projection,tenantId={}, kbId={}, sourceEventId={}, invalidOwnerProjectionCount={}",
+ event.getTenantId(), event.getKbId(), event.getSourceEventId(),
+ auditSummary.invalidOwnerProjectionCount());
+ }
+ return ownerFanoutSummary(auditSummary, queued);
+ }
+
+ private MuseKnowledgeEventPublishOutboxDO buildOutbox(MuseKnowledgeSourceEventDO event, String knowledgeEventType,
+ MuseKnowledgeSourceBindingProjectionDO projection,
+ int affectedOwnerProjectionCount) {
+ Long targetOwnerUserId = projection.getOwnerUserId();
+ String commandId = buildCommandId(event.getTenantId(), event.getCommandId(), event.getSourceEventId(),
+ targetOwnerUserId);
+ MuseKnowledgeEventPublishOutboxDO outbox = new MuseKnowledgeEventPublishOutboxDO();
+ outbox.setTenantId(event.getTenantId());
+ outbox.setOutboxId(buildOutboxId(event.getTenantId(), event.getSourceEventId(), targetOwnerUserId));
+ outbox.setSourceEventId(event.getSourceEventId());
+ outbox.setSourceFactOwnerUserId(event.getOwnerUserId());
+ outbox.setTargetOwnerUserId(targetOwnerUserId);
+ outbox.setKbId(event.getKbId());
+ outbox.setSourceType(event.getSourceType());
+ outbox.setSourceId(event.getSourceId());
+ outbox.setSourceRevision(normalizeSourceRevision(event.getSourceRevision()));
+ outbox.setSourceStatus(event.getStatus());
+ outbox.setActionPolicy(event.getActionPolicy());
+ outbox.setKnowledgeEventType(knowledgeEventType);
+ outbox.setEventType(EVENT_TYPE_NOTIFICATION);
+ outbox.setNotificationType(NOTIFICATION_SOURCE_STATUS_CHANGE);
+ outbox.setTargetWorkId(projection.getWorkId());
+ outbox.setBindingId(projection.getBindingId());
+ outbox.setProjectionId(projection.getProjectionId());
+ outbox.setAffectedOwnerProjectionCount(affectedOwnerProjectionCount);
+ outbox.setPayloadSummary(JsonUtils.toJsonString(notificationPayload(knowledgeEventType)));
+ outbox.setCommandId(commandId);
+ outbox.setPublishStatus(STATUS_QUEUED);
+ outbox.setAttemptCount(0);
+ outbox.setMaxAttempt(DEFAULT_MAX_ATTEMPT);
+ return outbox;
+ }
+
+ private Map notificationPayload(String knowledgeEventType) {
+ Map payload = new LinkedHashMap<>();
+ payload.put("type", NOTIFICATION_SOURCE_STATUS_CHANGE);
+ payload.put("message", messageFor(knowledgeEventType));
+ return payload;
+ }
+
+ private String knowledgeEventType(MuseKnowledgeSourceEventDO event) {
+ Map summary = parseMap(event.getEventSummary());
+ Object eventType = summary.get("eventType");
+ return eventType instanceof String value ? value : "";
+ }
+
+ private Map parseMap(String json) {
+ if (!StringUtils.hasText(json)) {
+ return Map.of();
+ }
+ try {
+ Map parsed = JsonUtils.parseObject(json, new TypeReference