From 165579521a32f605f3c2afd4ccabe589f7237312 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 5 Jun 2026 22:20:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(p1r):=20=E8=90=BD=E5=9C=B0=20Events=20SSE?= =?UTF-8?q?=20=E7=9C=9F=E5=AE=9E=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/AppMuseEventsController.java | 51 -- .../muse-module-events-api/pom.xml | 36 ++ .../events/api/publish/EventsPublishApi.java | 24 + .../api/publish/dto/EventsPublishReqDTO.java | 50 ++ .../api/publish/dto/EventsPublishRespDTO.java | 23 + .../module/events/enums/ApiConstants.java | 19 + .../events/enums/ErrorCodeConstants.java | 16 + .../muse-module-events-server/pom.xml | 47 ++ .../api/publish/EventsPublishApiImpl.java | 27 + .../publish/EventsPublishService.java | 19 + .../publish/EventsPublishServiceImpl.java | 286 ++++++++ .../stream/EventsStreamService.java | 20 + .../stream/EventsStreamServiceImpl.java | 414 ++++++++++++ .../app/AppMuseEventsController.java | 44 ++ .../events/dal/dataobject/UnifiedEventDO.java | 67 ++ .../events/dal/mysql/UnifiedEventMapper.java | 80 +++ .../dal/type/JsonbStringTypeHandler.java | 48 ++ .../module/events/domain/EventsCursor.java | 63 ++ .../events/domain/EventsPayloadSanitizer.java | 96 +++ .../config/EventsStreamConfiguration.java | 34 + .../publish/EventsPublishServiceTest.java | 450 +++++++++++++ .../stream/EventsStreamServiceTest.java | 324 ++++++++++ .../dal/mysql/UnifiedEventMapperTest.java | 110 ++++ .../events/domain/EventsCursorTest.java | 57 ++ .../config/EventsStreamConfigurationTest.java | 32 + muse-cloud/muse-module-events/pom.xml | 19 + muse-cloud/muse-server/pom.xml | 5 + .../api/P1rEventsFlywayMigrationIT.java | 610 ++++++++++++++++++ .../api/P1rEventsMigrationSqlTest.java | 239 +++++++ .../api/P1rEventsRouteOwnershipTest.java | 207 ++++++ muse-cloud/pom.xml | 1 + .../muse/V16__extend_events_sse_schema.sql | 52 ++ 32 files changed, 3519 insertions(+), 51 deletions(-) delete mode 100644 muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/controller/app/AppMuseEventsController.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-api/pom.xml create mode 100644 muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/EventsPublishApi.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/dto/EventsPublishReqDTO.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/dto/EventsPublishRespDTO.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/enums/ApiConstants.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/enums/ErrorCodeConstants.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/pom.xml create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/api/publish/EventsPublishApiImpl.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/publish/EventsPublishService.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/publish/EventsPublishServiceImpl.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/stream/EventsStreamService.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/stream/EventsStreamServiceImpl.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/controller/app/AppMuseEventsController.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/dataobject/UnifiedEventDO.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/mysql/UnifiedEventMapper.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/type/JsonbStringTypeHandler.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/domain/EventsCursor.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/domain/EventsPayloadSanitizer.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/framework/config/EventsStreamConfiguration.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/application/publish/EventsPublishServiceTest.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/application/stream/EventsStreamServiceTest.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/dal/mysql/UnifiedEventMapperTest.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/domain/EventsCursorTest.java create mode 100644 muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/framework/config/EventsStreamConfigurationTest.java create mode 100644 muse-cloud/muse-module-events/pom.xml create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsFlywayMigrationIT.java create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsMigrationSqlTest.java create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRouteOwnershipTest.java create mode 100644 muse-cloud/sql/muse/V16__extend_events_sse_schema.sql diff --git a/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/controller/app/AppMuseEventsController.java b/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/controller/app/AppMuseEventsController.java deleted file mode 100644 index 4a2f7bab..00000000 --- a/muse-cloud/muse-module-content/muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/controller/app/AppMuseEventsController.java +++ /dev/null @@ -1,51 +0,0 @@ -package cn.iocoder.muse.module.content.controller.app; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.Map; - -/** - * 用户端 Muse 统一事件流入口。 - * - *

P1 阶段先提供可建立连接的 SSE 端点,后续由各业务模块接入真实事件发布器。

- */ -@Tag(name = "用户 APP - Muse Events") -@RestController -@RequestMapping("/muse") -public class AppMuseEventsController { - - /** SSE 连接默认超时时间,避免占用容器线程过久。 */ - private static final long DEFAULT_TIMEOUT_MILLIS = 30_000L; - - /** - * 建立统一事件流 SSE 连接。 - * - * @return SSE 事件流 - */ - @GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - @Operation(summary = "统一事件流 SSE") - public SseEmitter streamEvents() { - SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT_MILLIS); - try { - emitter.send(SseEmitter.event() - .name("notification") - .data(Map.of( - "type", "source_status_change", - "message", "muse event stream ready", - "timestamp", LocalDateTime.now().toString()))); - emitter.complete(); - } catch (IOException ex) { - emitter.completeWithError(ex); - } - return emitter; - } - -} diff --git a/muse-cloud/muse-module-events/muse-module-events-api/pom.xml b/muse-cloud/muse-module-events/muse-module-events-api/pom.xml new file mode 100644 index 00000000..394d3f5e --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-api/pom.xml @@ -0,0 +1,36 @@ + + + + cn.iocoder.cloud + muse-module-events + ${revision} + + 4.0.0 + muse-module-events-api + jar + ${project.artifactId} + events 模块 API 和发布契约。 + + + cn.iocoder.cloud + muse-common + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + provided + + + org.springframework.boot + spring-boot-starter-validation + true + + + org.springframework.cloud + spring-cloud-starter-openfeign + true + + + diff --git a/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/EventsPublishApi.java b/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/EventsPublishApi.java new file mode 100644 index 00000000..bc92c5e4 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/EventsPublishApi.java @@ -0,0 +1,24 @@ +package cn.iocoder.muse.module.events.api.publish; + +import cn.iocoder.muse.framework.common.pojo.CommonResult; +import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishReqDTO; +import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO; +import cn.iocoder.muse.module.events.enums.ApiConstants; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient(name = ApiConstants.NAME) +@Tag(name = "RPC 服务 - Events 统一事件发布") +public interface EventsPublishApi { + + String PREFIX = ApiConstants.PREFIX + "/publish"; + + @PostMapping(PREFIX) + @Operation(summary = "幂等发布统一事件投影") + CommonResult publish(@Valid @RequestBody EventsPublishReqDTO reqDTO); + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/dto/EventsPublishReqDTO.java b/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/dto/EventsPublishReqDTO.java new file mode 100644 index 00000000..eb4f36ea --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/dto/EventsPublishReqDTO.java @@ -0,0 +1,50 @@ +package cn.iocoder.muse.module.events.api.publish.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Map; + +/** + * Events 统一事件发布请求。 + */ +@Data +public class EventsPublishReqDTO implements Serializable { + + @NotEmpty(message = "commandId 不能为空") + private String commandId; + + @NotNull(message = "tenantId 不能为空") + private Long tenantId; + + @NotNull(message = "ownerUserId 不能为空") + private Long ownerUserId; + + @NotEmpty(message = "sourceOwner 不能为空") + private String sourceOwner; + + @NotEmpty(message = "sourceType 不能为空") + private String sourceType; + + @NotEmpty(message = "sourceId 不能为空") + private String sourceId; + + private String sourceRevision; + + @NotEmpty(message = "eventType 不能为空") + private String eventType; + + private String resourceType; + + private String resourceId; + + @NotNull(message = "payloadSummary 不能为空") + private Map payloadSummary; + + @NotNull(message = "emittedAt 不能为空") + private LocalDateTime emittedAt; + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/dto/EventsPublishRespDTO.java b/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/dto/EventsPublishRespDTO.java new file mode 100644 index 00000000..f9446678 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/api/publish/dto/EventsPublishRespDTO.java @@ -0,0 +1,23 @@ +package cn.iocoder.muse.module.events.api.publish.dto; + +import lombok.Data; + +import java.io.Serializable; + +/** + * Events 统一事件发布响应。 + */ +@Data +public class EventsPublishRespDTO implements Serializable { + + private String eventId; + + private Long sequenceNo; + + private String publishStatus; + + private String publishErrorCode; + + private Boolean duplicate; + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/enums/ApiConstants.java b/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/enums/ApiConstants.java new file mode 100644 index 00000000..9de03943 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/enums/ApiConstants.java @@ -0,0 +1,19 @@ +package cn.iocoder.muse.module.events.enums; + +import cn.iocoder.muse.framework.common.enums.RpcConstants; + +/** + * Events 模块 RPC API 常量。 + */ +public class ApiConstants { + + /** + * 服务名需要和承载 Events server 的应用名保持一致。 + */ + public static final String NAME = "events-server"; + + public static final String PREFIX = RpcConstants.RPC_API_PREFIX + "/events"; + + public static final String VERSION = "1.0.0"; + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/enums/ErrorCodeConstants.java b/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/enums/ErrorCodeConstants.java new file mode 100644 index 00000000..6acc6ee5 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-api/src/main/java/cn/iocoder/muse/module/events/enums/ErrorCodeConstants.java @@ -0,0 +1,16 @@ +package cn.iocoder.muse.module.events.enums; + +import cn.iocoder.muse.framework.common.exception.ErrorCode; + +/** + * Events 错误码。 + * + *

Events 使用 1-045-000-000 段,覆盖统一事件发布、幂等投影和 SSE 可见性边界。

+ */ +public interface ErrorCodeConstants { + + // ========== Events 发布契约 1-045-000-000 ========== + ErrorCode EVENTS_PUBLISH_COMMAND_ID_REQUIRED = new ErrorCode(1_045_000_000, "事件发布 commandId 不能为空"); + ErrorCode EVENTS_PUBLISH_REQUIRED_FIELD_EMPTY = new ErrorCode(1_045_000_001, "事件发布必要字段不能为空:{}"); + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/pom.xml b/muse-cloud/muse-module-events/muse-module-events-server/pom.xml new file mode 100644 index 00000000..a76f56ef --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/pom.xml @@ -0,0 +1,47 @@ + + + + cn.iocoder.cloud + muse-module-events + ${revision} + + 4.0.0 + muse-module-events-server + jar + ${project.artifactId} + events 模块服务实现。 + + + cn.iocoder.cloud + muse-module-events-api + ${revision} + + + cn.iocoder.cloud + muse-spring-boot-starter-web + + + cn.iocoder.cloud + muse-spring-boot-starter-security + + + cn.iocoder.cloud + muse-spring-boot-starter-biz-tenant + + + cn.iocoder.cloud + muse-spring-boot-starter-mybatis + + + org.springframework.boot + spring-boot-starter-validation + + + cn.iocoder.cloud + muse-spring-boot-starter-test + test + + + diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/api/publish/EventsPublishApiImpl.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/api/publish/EventsPublishApiImpl.java new file mode 100644 index 00000000..098e09be --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/api/publish/EventsPublishApiImpl.java @@ -0,0 +1,27 @@ +package cn.iocoder.muse.module.events.api.publish; + +import cn.iocoder.muse.framework.common.pojo.CommonResult; +import cn.iocoder.muse.module.events.api.publish.EventsPublishApi; +import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishReqDTO; +import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO; +import cn.iocoder.muse.module.events.application.publish.EventsPublishService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.annotation.Resource; + +import static cn.iocoder.muse.framework.common.pojo.CommonResult.success; + +@RestController +@Validated +public class EventsPublishApiImpl implements EventsPublishApi { + + @Resource + private EventsPublishService eventsPublishService; + + @Override + public CommonResult publish(EventsPublishReqDTO reqDTO) { + return success(eventsPublishService.publish(reqDTO)); + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/publish/EventsPublishService.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/publish/EventsPublishService.java new file mode 100644 index 00000000..1fc8918f --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/publish/EventsPublishService.java @@ -0,0 +1,19 @@ +package cn.iocoder.muse.module.events.application.publish; + +import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishReqDTO; +import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO; +import cn.iocoder.muse.module.events.dal.dataobject.UnifiedEventDO; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Events 发布应用服务。 + */ +public interface EventsPublishService { + + EventsPublishRespDTO publish(EventsPublishReqDTO reqDTO); + + List listVisibleEvents(Long tenantId, Long ownerUserId, Long afterSequenceNo, LocalDateTime now); + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/publish/EventsPublishServiceImpl.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/publish/EventsPublishServiceImpl.java new file mode 100644 index 00000000..881232ae --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/publish/EventsPublishServiceImpl.java @@ -0,0 +1,286 @@ +package cn.iocoder.muse.module.events.application.publish; + +import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishReqDTO; +import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO; +import cn.iocoder.muse.module.events.dal.dataobject.UnifiedEventDO; +import cn.iocoder.muse.module.events.dal.mysql.UnifiedEventMapper; +import cn.iocoder.muse.module.events.domain.EventsPayloadSanitizer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Events 发布应用服务实现。 + */ +@Service +@Slf4j +public class EventsPublishServiceImpl implements EventsPublishService { + + public static final String SOURCE_REVISION_NONE = "__none__"; + + public static final String PUBLISH_STATUS_ACCEPTED = "accepted"; + public static final String PUBLISH_STATUS_REJECTED = "rejected"; + public static final String PUBLISH_STATUS_BLOCKED = "blocked"; + + public static final String ERROR_PAYLOAD_CONTAINS_SECRET = "payload_contains_secret"; + public static final String ERROR_EVENT_TYPE_NOT_DECLARED = "event_type_not_declared"; + public static final String ERROR_PAYLOAD_SCHEMA_MISMATCH = "payload_schema_mismatch"; + + private static final BigInteger LONG_MIN_VALUE = BigInteger.valueOf(Long.MIN_VALUE); + private static final BigInteger LONG_MAX_VALUE = BigInteger.valueOf(Long.MAX_VALUE); + + private static final String EVENT_TYPE_CHUNK = "chunk"; + private static final String EVENT_TYPE_QUALITY_CHECK = "quality_check"; + private static final String EVENT_TYPE_DONE = "done"; + private static final String EVENT_TYPE_ERROR = "error"; + private static final String EVENT_TYPE_NOTIFICATION = "notification"; + + private static final Set DECLARED_EVENT_TYPES = Set.of(EVENT_TYPE_CHUNK, EVENT_TYPE_QUALITY_CHECK, + EVENT_TYPE_DONE, EVENT_TYPE_ERROR, EVENT_TYPE_NOTIFICATION); + private static final Set NOTIFICATION_TYPES = Set.of("source_status_change", "knowledge_projection_done", + "governance_action", "quota_alert"); + + private final UnifiedEventMapper unifiedEventMapper; + private final EventsPayloadSanitizer payloadSanitizer; + + public EventsPublishServiceImpl(UnifiedEventMapper unifiedEventMapper) { + this(unifiedEventMapper, new EventsPayloadSanitizer()); + } + + @Autowired + public EventsPublishServiceImpl(UnifiedEventMapper unifiedEventMapper, EventsPayloadSanitizer payloadSanitizer) { + this.unifiedEventMapper = unifiedEventMapper; + this.payloadSanitizer = payloadSanitizer; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public EventsPublishRespDTO publish(EventsPublishReqDTO reqDTO) { + validateRequiredFields(reqDTO); + String sourceRevision = normalizeSourceRevision(reqDTO.getSourceRevision()); + + // 幂等顺序是合同的一部分:先按 commandId 回放,再按 source tuple 回放,避免 source owner retry 生成重复事件。 + UnifiedEventDO existingByCommand = unifiedEventMapper.selectByTenantIdAndCommandId(reqDTO.getTenantId(), + reqDTO.getCommandId()); + if (existingByCommand != null) { + return toResp(existingByCommand, true); + } + UnifiedEventDO existingBySourceTuple = unifiedEventMapper.selectByTenantIdAndSourceTuple(reqDTO.getTenantId(), + reqDTO.getSourceOwner(), reqDTO.getSourceType(), reqDTO.getSourceId(), sourceRevision, + normalizeEventTypeForLookup(reqDTO.getEventType())); + if (existingBySourceTuple != null) { + return toResp(existingBySourceTuple, true); + } + + UnifiedEventDO event = buildEvent(reqDTO, sourceRevision); + int inserted = unifiedEventMapper.insertIgnore(event); + UnifiedEventDO stored = findStoredEvent(reqDTO, sourceRevision); + if (stored == null) { + // insertIgnore 被 DB 约束吞掉但回查失败,说明调用方传入了互相冲突的 command/source tuple;fail-closed 记录日志。 + log.warn("Events 发布幂等回查失败: tenantId={}, commandId={}, sourceOwner={}, sourceType={}, sourceId={}, sourceRevision={}, eventType={}, inserted={}", + reqDTO.getTenantId(), reqDTO.getCommandId(), reqDTO.getSourceOwner(), reqDTO.getSourceType(), + reqDTO.getSourceId(), sourceRevision, reqDTO.getEventType(), inserted); + throw new IllegalStateException("Events 发布幂等回查失败,拒绝返回未落库事件"); + } + return toResp(stored, inserted == 0); + } + + @Override + @Transactional(readOnly = true) + public List listVisibleEvents(Long tenantId, Long ownerUserId, Long afterSequenceNo, + LocalDateTime now) { + return unifiedEventMapper.selectVisibleEventsForOwner(tenantId, ownerUserId, + afterSequenceNo == null ? 0L : afterSequenceNo, now == null ? LocalDateTime.now() : now); + } + + private UnifiedEventDO buildEvent(EventsPublishReqDTO reqDTO, String sourceRevision) { + EventsPayloadSanitizer.SanitizedPayload sanitizedPayload = payloadSanitizer.sanitize(reqDTO.getPayloadSummary()); + PublishValidation validation = validatePublishPayload(reqDTO, sanitizedPayload); + + UnifiedEventDO event = new UnifiedEventDO(); + event.setTenantId(reqDTO.getTenantId()); + event.setCommandId(reqDTO.getCommandId()); + event.setEventId("evt_" + UUID.randomUUID()); + event.setEventType(validation.persistedEventType); + event.setOwnerUserId(reqDTO.getOwnerUserId()); + event.setSourceOwner(reqDTO.getSourceOwner()); + event.setSourceType(reqDTO.getSourceType()); + event.setSourceId(reqDTO.getSourceId()); + event.setSourceRevision(sourceRevision); + event.setResourceType(reqDTO.getResourceType()); + event.setResourceId(reqDTO.getResourceId()); + event.setPayloadSummary(sanitizedPayload.getPayloadJson()); + event.setPublishStatus(validation.publishStatus); + event.setPublishErrorCode(validation.publishErrorCode); + // 可见时间只对 accepted 有意义;rejected/blocked 仍写入统一审计记录,但 mapper 查询会 fail-closed 排除。 + event.setVisibleFrom(LocalDateTime.now()); + event.setEmittedAt(reqDTO.getEmittedAt()); + event.setDeleted(false); + return event; + } + + private PublishValidation validatePublishPayload(EventsPublishReqDTO reqDTO, + EventsPayloadSanitizer.SanitizedPayload sanitizedPayload) { + if (!DECLARED_EVENT_TYPES.contains(reqDTO.getEventType())) { + // V16 DDL 对 event_type 有 CHECK 约束;非法类型必须记录 rejected,但不能把非法值写进 event_type。 + return PublishValidation.rejected(EVENT_TYPE_ERROR, ERROR_EVENT_TYPE_NOT_DECLARED); + } + if (sanitizedPayload.isContainsSecret()) { + return PublishValidation.rejected(reqDTO.getEventType(), ERROR_PAYLOAD_CONTAINS_SECRET); + } + if (!matchesOpenApiEventSchema(reqDTO.getEventType(), reqDTO.getPayloadSummary())) { + return PublishValidation.rejected(reqDTO.getEventType(), ERROR_PAYLOAD_SCHEMA_MISMATCH); + } + return PublishValidation.accepted(reqDTO.getEventType()); + } + + private boolean matchesOpenApiEventSchema(String eventType, Map payload) { + Map safePayload = payload == null ? Map.of() : payload; + return switch (eventType) { + case EVENT_TYPE_CHUNK -> isRequiredString(safePayload, "content") + && isPositiveLongNumber(safePayload.get("sequenceNo")); + case EVENT_TYPE_QUALITY_CHECK -> isRequiredString(safePayload, "dimension") + && isScoreInOpenApiRange(safePayload.get("score")) + && safePayload.get("passed") instanceof Boolean; + case EVENT_TYPE_DONE -> isLongNumber(safePayload.get("taskId")) + && isLongNumber(safePayload.get("suggestionId")); + case EVENT_TYPE_ERROR -> isRequiredString(safePayload, "code") + && isRequiredString(safePayload, "message"); + case EVENT_TYPE_NOTIFICATION -> safePayload.get("type") instanceof String type + && NOTIFICATION_TYPES.contains(type) + && isRequiredString(safePayload, "message"); + default -> false; + }; + } + + private UnifiedEventDO findStoredEvent(EventsPublishReqDTO reqDTO, String sourceRevision) { + UnifiedEventDO existingByCommand = unifiedEventMapper.selectByTenantIdAndCommandId(reqDTO.getTenantId(), + reqDTO.getCommandId()); + if (existingByCommand != null) { + return existingByCommand; + } + return unifiedEventMapper.selectByTenantIdAndSourceTuple(reqDTO.getTenantId(), reqDTO.getSourceOwner(), + reqDTO.getSourceType(), reqDTO.getSourceId(), sourceRevision, + normalizeEventTypeForLookup(reqDTO.getEventType())); + } + + private String normalizeEventTypeForLookup(String eventType) { + return DECLARED_EVENT_TYPES.contains(eventType) ? eventType : EVENT_TYPE_ERROR; + } + + private String normalizeSourceRevision(String sourceRevision) { + return StringUtils.hasText(sourceRevision) ? sourceRevision : SOURCE_REVISION_NONE; + } + + private void validateRequiredFields(EventsPublishReqDTO reqDTO) { + if (reqDTO == null) { + throw new IllegalArgumentException("Events publish 请求不能为空"); + } + requireText(reqDTO.getCommandId(), "commandId"); + requireNonNull(reqDTO.getTenantId(), "tenantId"); + requireNonNull(reqDTO.getOwnerUserId(), "ownerUserId"); + requireText(reqDTO.getSourceOwner(), "sourceOwner"); + requireText(reqDTO.getSourceType(), "sourceType"); + requireText(reqDTO.getSourceId(), "sourceId"); + requireText(reqDTO.getEventType(), "eventType"); + requireNonNull(reqDTO.getPayloadSummary(), "payloadSummary"); + requireNonNull(reqDTO.getEmittedAt(), "emittedAt"); + } + + private void requireText(String value, String fieldName) { + if (!StringUtils.hasText(value)) { + throw new IllegalArgumentException(fieldName + " 不能为空"); + } + } + + private void requireNonNull(Object value, String fieldName) { + if (value == null) { + throw new IllegalArgumentException(fieldName + " 不能为空"); + } + } + + private boolean isRequiredString(Map payload, String fieldName) { + // OpenAPI 未声明 minLength 的 string 只校验字段存在、非 null 且类型为 String;空字符串是合法合同值。 + return payload.containsKey(fieldName) && payload.get(fieldName) instanceof String; + } + + private boolean isLongNumber(Object value) { + return toLongValue(value) != null; + } + + private boolean isPositiveLongNumber(Object value) { + Long longValue = toLongValue(value); + return longValue != null && longValue >= 1L; + } + + private Long toLongValue(Object value) { + if (!(value instanceof Number number)) { + return null; + } + if (number instanceof Byte || number instanceof Short || number instanceof Integer || number instanceof Long) { + return number.longValue(); + } + if (number instanceof BigInteger bigInteger) { + return isLongRange(bigInteger) ? bigInteger.longValue() : null; + } + if (number instanceof BigDecimal bigDecimal) { + return toLongValue(bigDecimal); + } + // OpenAPI int64 只接受整数语义的 Number;Float/Double 即使值为 1.0 也不作为 int64 入库。 + return null; + } + + private Long toLongValue(BigDecimal bigDecimal) { + try { + BigInteger integerValue = bigDecimal.toBigIntegerExact(); + return isLongRange(integerValue) ? integerValue.longValue() : null; + } catch (ArithmeticException ex) { + return null; + } + } + + private boolean isLongRange(BigInteger value) { + return value.compareTo(LONG_MIN_VALUE) >= 0 && value.compareTo(LONG_MAX_VALUE) <= 0; + } + + private boolean isScoreInOpenApiRange(Object value) { + if (!(value instanceof Number number)) { + return false; + } + double score = number.doubleValue(); + return Double.isFinite(score) && score >= 0D && score <= 1D; + } + + private EventsPublishRespDTO toResp(UnifiedEventDO event, boolean duplicate) { + EventsPublishRespDTO respDTO = new EventsPublishRespDTO(); + respDTO.setEventId(event.getEventId()); + respDTO.setSequenceNo(event.getSequenceNo()); + respDTO.setPublishStatus(event.getPublishStatus()); + respDTO.setPublishErrorCode(event.getPublishErrorCode()); + respDTO.setDuplicate(duplicate); + return respDTO; + } + + private record PublishValidation(String publishStatus, String publishErrorCode, String persistedEventType) { + + static PublishValidation accepted(String eventType) { + return new PublishValidation(PUBLISH_STATUS_ACCEPTED, null, eventType); + } + + static PublishValidation rejected(String eventType, String errorCode) { + return new PublishValidation(PUBLISH_STATUS_REJECTED, errorCode, eventType); + } + + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/stream/EventsStreamService.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/stream/EventsStreamService.java new file mode 100644 index 00000000..1980c0c9 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/stream/EventsStreamService.java @@ -0,0 +1,20 @@ +package cn.iocoder.muse.module.events.application.stream; + +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * Events 统一 SSE stream 应用服务。 + */ +public interface EventsStreamService { + + /** + * 建立统一事件流。 + * + * @param loginUserId 当前登录用户;必须由 controller 在进入 SSE 生命周期前取得 + * @param apiVersion `X-API-Version` 请求头 + * @param lastEventId SSE Last-Event-ID / 查询参数游标 + * @return SSE emitter + */ + SseEmitter streamEvents(Long loginUserId, String apiVersion, String lastEventId); + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/stream/EventsStreamServiceImpl.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/stream/EventsStreamServiceImpl.java new file mode 100644 index 00000000..7a4e188e --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/application/stream/EventsStreamServiceImpl.java @@ -0,0 +1,414 @@ +package cn.iocoder.muse.module.events.application.stream; + +import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.muse.module.events.dal.dataobject.UnifiedEventDO; +import cn.iocoder.muse.module.events.dal.mysql.UnifiedEventMapper; +import cn.iocoder.muse.module.events.domain.EventsCursor; +import cn.iocoder.muse.module.events.domain.EventsPayloadSanitizer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; + +import static cn.iocoder.muse.module.events.framework.config.EventsStreamConfiguration.EVENTS_STREAM_EXECUTOR; + +/** + * Events 统一 SSE stream 应用服务实现。 + */ +@Service +@Slf4j +public class EventsStreamServiceImpl implements EventsStreamService { + + private static final String SUPPORTED_API_VERSION = "1"; + private static final long DEFAULT_TIMEOUT_MILLIS = 30_000L; + private static final long HEARTBEAT_INTERVAL_MILLIS = 5_000L; + + private static final String EVENT_CHUNK = "chunk"; + private static final String EVENT_QUALITY_CHECK = "quality_check"; + private static final String EVENT_DONE = "done"; + private static final String EVENT_ERROR = "error"; + private static final String EVENT_NOTIFICATION = "notification"; + + private static final String ERROR_API_VERSION_UNSUPPORTED = "EVENTS_API_VERSION_UNSUPPORTED"; + private static final String ERROR_STREAM_UNAVAILABLE = "EVENTS_STREAM_UNAVAILABLE"; + + private static final Set DECLARED_EVENT_TYPES = Set.of(EVENT_CHUNK, EVENT_QUALITY_CHECK, EVENT_DONE, + EVENT_ERROR, EVENT_NOTIFICATION); + private static final Set NOTIFICATION_TYPES = Set.of("source_status_change", "knowledge_projection_done", + "governance_action", "quota_alert"); + private static final Pattern SECRET_TEXT_PATTERN = Pattern.compile( + "(?i)(bearer\\s+|authorization|token|secret|api[-_]?key|provider\\s+raw\\s+body|raw\\s+body)"); + + private final UnifiedEventMapper unifiedEventMapper; + private final EventsPayloadSanitizer payloadSanitizer; + private final AsyncTaskExecutor eventsStreamExecutor; + + @Autowired + public EventsStreamServiceImpl(UnifiedEventMapper unifiedEventMapper, EventsPayloadSanitizer payloadSanitizer, + @org.springframework.beans.factory.annotation.Qualifier(EVENTS_STREAM_EXECUTOR) + AsyncTaskExecutor eventsStreamExecutor) { + this.unifiedEventMapper = unifiedEventMapper; + this.payloadSanitizer = payloadSanitizer; + this.eventsStreamExecutor = eventsStreamExecutor; + } + + @Override + public SseEmitter streamEvents(Long loginUserId, String apiVersion, String lastEventId) { + Long tenantId = TenantContextHolder.getTenantId(); + SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT_MILLIS); + AtomicBoolean active = new AtomicBoolean(true); + AtomicReference> pollingFutureRef = new AtomicReference<>(); + registerLifecycleCallbacks(emitter, active, pollingFutureRef); + + if (!SUPPORTED_API_VERSION.equals(apiVersion)) { + // 不支持的版本已进入 SSE 生命周期,必须发送 OpenAPI error event,不能返回普通 400。 + log.warn("Events SSE API 版本不支持,tenantId={}, ownerUserId={}, apiVersionSupported={}", + tenantId, loginUserId, false); + completeWithSafeError(emitter, ERROR_API_VERSION_UNSUPPORTED, "Events API version unsupported", false); + return emitter; + } + + EventsCursor cursor = EventsCursor.parse(lastEventId); + if (!cursor.isValid()) { + // cursor 原文可能来自浏览器或代理,日志和响应都不回显,避免把异常输入变成信息泄露面。 + log.warn("Events SSE cursor 非法,tenantId={}, ownerUserId={}", tenantId, loginUserId); + completeWithSafeError(emitter, cursor.errorCode(), cursor.errorMessage(), false); + return emitter; + } + + try { + Long startSequenceNo = initialSequenceNo(tenantId, loginUserId, cursor); + StreamBatch initialBatch = cursor.isInitialConnection() + ? StreamBatch.empty(startSequenceNo) + : loadVisibleBatch(tenantId, loginUserId, startSequenceNo); + sendBatchOrHeartbeat(emitter, initialBatch); + startPollingPersistedEvents(emitter, active, pollingFutureRef, tenantId, loginUserId, + initialBatch.maxSequenceNo()); + } catch (RejectedExecutionException ex) { + active.set(false); + log.warn("Events SSE 轮询提交被拒绝,tenantId={}, ownerUserId={}", tenantId, loginUserId); + completeWithSafeError(emitter, ERROR_STREAM_UNAVAILABLE, "Events stream unavailable", true); + } catch (IOException ex) { + active.set(false); + emitter.completeWithError(ex); + } catch (Exception ex) { + active.set(false); + log.warn("Events SSE 初始化失败,tenantId={}, ownerUserId={}, errorType={}", tenantId, loginUserId, + ex.getClass().getSimpleName()); + completeWithSafeError(emitter, ERROR_STREAM_UNAVAILABLE, "Events stream unavailable", true); + } + return emitter; + } + + private void registerLifecycleCallbacks(SseEmitter emitter, AtomicBoolean active, + AtomicReference> pollingFutureRef) { + emitter.onTimeout(() -> { + active.set(false); + cancelPolling(pollingFutureRef); + emitter.complete(); + }); + emitter.onCompletion(() -> { + active.set(false); + cancelPolling(pollingFutureRef); + }); + emitter.onError(error -> { + active.set(false); + cancelPolling(pollingFutureRef); + }); + } + + private Long initialSequenceNo(Long tenantId, Long ownerUserId, EventsCursor cursor) { + if (!cursor.isInitialConnection()) { + return cursor.sequenceNo(); + } + Long maxSequenceNo = unifiedEventMapper.selectMaxVisibleSequenceForOwner(tenantId, ownerUserId, + LocalDateTime.now()); + // 空 cursor 首连冻结当前可见最大 sequence,后续只追更大的事件;不能 replay 连接前历史。 + return maxSequenceNo == null ? 0L : maxSequenceNo; + } + + private StreamBatch loadVisibleBatch(Long tenantId, Long ownerUserId, Long afterSequenceNo) { + List rows = unifiedEventMapper.selectVisibleEventsForOwner(tenantId, ownerUserId, + afterSequenceNo == null ? 0L : afterSequenceNo, LocalDateTime.now()); + return toStreamBatch(afterSequenceNo, rows); + } + + private StreamBatch toStreamBatch(Long fallbackSequenceNo, List rows) { + Long maxSequenceNo = fallbackSequenceNo == null ? 0L : fallbackSequenceNo; + if (rows == null || rows.isEmpty()) { + return StreamBatch.empty(maxSequenceNo); + } + List events = rows.stream() + .map(this::toStreamEvent) + .filter(Objects::nonNull) + .toList(); + for (UnifiedEventDO row : rows) { + if (row != null && row.getSequenceNo() != null) { + maxSequenceNo = Math.max(maxSequenceNo, row.getSequenceNo()); + } + } + return new StreamBatch(events, maxSequenceNo); + } + + private StreamEvent toStreamEvent(UnifiedEventDO row) { + if (row == null || row.getSequenceNo() == null || !DECLARED_EVENT_TYPES.contains(row.getEventType())) { + // 落库数据异常时不发送未知 event 名称,避免前端收到 OpenAPI 外事件。 + log.warn("Events SSE 跳过非法事件行,tenantId={}, ownerUserId={}, sequenceNo={}, eventType={}", + row == null ? null : row.getTenantId(), row == null ? null : row.getOwnerUserId(), + row == null ? null : row.getSequenceNo(), row == null ? null : row.getEventType()); + return null; + } + Map payload = payloadSanitizer.parseSanitizedJson(row.getPayloadSummary()); + Map data = toOpenApiData(row.getEventType(), payload); + return data == null ? null : new StreamEvent(row.getSequenceNo(), row.getEventType(), data); + } + + private Map toOpenApiData(String eventType, Map payload) { + return switch (eventType) { + case EVENT_CHUNK -> chunkData(payload); + case EVENT_QUALITY_CHECK -> qualityCheckData(payload); + case EVENT_DONE -> doneData(payload); + case EVENT_ERROR -> errorData(payload); + case EVENT_NOTIFICATION -> notificationData(payload); + default -> null; + }; + } + + private Map chunkData(Map payload) { + if (!(payload.get("content") instanceof String content) || !isPositiveLongNumber(payload.get("sequenceNo"))) { + return null; + } + Map data = new LinkedHashMap<>(); + data.put("content", content); + data.put("sequenceNo", toLong(payload.get("sequenceNo"))); + return data; + } + + private Map qualityCheckData(Map payload) { + if (!(payload.get("dimension") instanceof String dimension) + || !(payload.get("score") instanceof Number score) + || !(payload.get("passed") instanceof Boolean passed)) { + return null; + } + double scoreValue = score.doubleValue(); + if (scoreValue < 0D || scoreValue > 1D) { + return null; + } + Map data = new LinkedHashMap<>(); + data.put("dimension", dimension); + data.put("score", scoreValue); + data.put("passed", passed); + return data; + } + + private Map doneData(Map payload) { + if (!isLongNumber(payload.get("taskId")) || !isLongNumber(payload.get("suggestionId"))) { + return null; + } + Map data = new LinkedHashMap<>(); + data.put("taskId", toLong(payload.get("taskId"))); + data.put("suggestionId", toLong(payload.get("suggestionId"))); + if (payload.get("summary") instanceof String summary) { + data.put("summary", summary); + } + return data; + } + + private Map errorData(Map payload) { + if (!(payload.get("code") instanceof String code) || !(payload.get("message") instanceof String message)) { + return null; + } + Map data = new LinkedHashMap<>(); + data.put("code", safeText(code, "EVENTS_ERROR")); + data.put("message", safeText(message, "Events stream error")); + if (payload.get("detail") instanceof String detail && !containsSecret(detail)) { + data.put("detail", detail); + } + if (payload.get("retryable") instanceof Boolean retryable) { + data.put("retryable", retryable); + } + return data; + } + + private Map notificationData(Map payload) { + if (!(payload.get("type") instanceof String type) || !NOTIFICATION_TYPES.contains(type) + || !(payload.get("message") instanceof String message)) { + return null; + } + Map data = new LinkedHashMap<>(); + data.put("type", type); + data.put("message", message); + if (payload.get("resourceRef") instanceof Map resourceRef) { + Map safeResourceRef = resourceRefData(resourceRef); + if (!safeResourceRef.isEmpty()) { + data.put("resourceRef", safeResourceRef); + } + } + if (payload.get("timestamp") instanceof String timestamp) { + data.put("timestamp", timestamp); + } + return data; + } + + private Map resourceRefData(Map resourceRef) { + Map data = new LinkedHashMap<>(); + Object resourceType = resourceRef.get("resourceType"); + if (resourceType instanceof String text && !containsSecret(text)) { + data.put("resourceType", text); + } + Object resourceId = resourceRef.get("resourceId"); + if (isLongNumber(resourceId)) { + data.put("resourceId", toLong(resourceId)); + } + return data; + } + + private void sendBatchOrHeartbeat(SseEmitter emitter, StreamBatch batch) throws IOException { + if (batch.events().isEmpty()) { + sendHeartbeat(emitter); + return; + } + for (StreamEvent event : batch.events()) { + // SSE id 承载全局恢复游标;data 只发送 OpenAPI 声明字段,避免扩展未声明字段。 + emitter.send(SseEmitter.event() + .id(EventsCursor.toSseId(event.sequenceNo())) + .name(event.event()) + .data(event.data(), MediaType.APPLICATION_JSON)); + } + } + + private void sendHeartbeat(SseEmitter emitter) throws IOException { + // 无事件时只能发送 comment heartbeat,不能伪造 notification 或 done。 + emitter.send(SseEmitter.event().comment("heartbeat")); + } + + private void startPollingPersistedEvents(SseEmitter emitter, AtomicBoolean active, + AtomicReference> pollingFutureRef, Long tenantId, + Long ownerUserId, Long lastSequenceNo) { + Future pollingFuture = eventsStreamExecutor.submit(() -> + pollPersistedEvents(emitter, active, tenantId, ownerUserId, lastSequenceNo)); + pollingFutureRef.set(pollingFuture); + if (!active.get()) { + cancelPolling(pollingFutureRef); + } + } + + private void pollPersistedEvents(SseEmitter emitter, AtomicBoolean active, Long tenantId, Long ownerUserId, + Long lastSequenceNo) { + Long currentSequenceNo = lastSequenceNo == null ? 0L : lastSequenceNo; + long deadline = System.currentTimeMillis() + DEFAULT_TIMEOUT_MILLIS; + TenantContextHolder.setTenantId(tenantId); + try { + while (active.get() && System.currentTimeMillis() < deadline) { + StreamBatch delta = loadVisibleBatch(tenantId, ownerUserId, currentSequenceNo); + currentSequenceNo = delta.maxSequenceNo(); + sendBatchOrHeartbeat(emitter, delta); + sleepHeartbeatInterval(active); + } + if (active.compareAndSet(true, false)) { + emitter.complete(); + } + } catch (IOException ex) { + active.set(false); + emitter.completeWithError(ex); + } catch (Exception ex) { + // 后台 DB/mapper/租户拦截器异常不透出原始 message;日志仅保留定位所需的安全维度。 + active.set(false); + log.warn("Events SSE 后台轮询失败,tenantId={}, ownerUserId={}, cursor={}, errorType={}", tenantId, + ownerUserId, currentSequenceNo, ex.getClass().getSimpleName()); + completeWithSafeError(emitter, ERROR_STREAM_UNAVAILABLE, "Events stream unavailable", true); + } finally { + TenantContextHolder.clear(); + } + } + + private void sleepHeartbeatInterval(AtomicBoolean active) { + try { + Thread.sleep(HEARTBEAT_INTERVAL_MILLIS); + } catch (InterruptedException ex) { + active.set(false); + Thread.currentThread().interrupt(); + } + } + + private void completeWithSafeError(SseEmitter emitter, String code, String message, Boolean retryable) { + try { + Map data = new LinkedHashMap<>(); + data.put("code", safeText(code, ERROR_STREAM_UNAVAILABLE)); + data.put("message", safeText(message, "Events stream unavailable")); + if (retryable != null) { + data.put("retryable", retryable); + } + emitter.send(SseEmitter.event() + .name(EVENT_ERROR) + .data(data, MediaType.APPLICATION_JSON)); + emitter.complete(); + } catch (IOException ex) { + emitter.completeWithError(ex); + } + } + + private void cancelPolling(AtomicReference> pollingFutureRef) { + Future pollingFuture = pollingFutureRef.getAndSet(null); + if (pollingFuture != null && !pollingFuture.isDone()) { + pollingFuture.cancel(true); + } + } + + private boolean isPositiveLongNumber(Object value) { + Long longValue = toLong(value); + return longValue != null && longValue >= 1L; + } + + private boolean isLongNumber(Object value) { + return toLong(value) != null; + } + + private Long toLong(Object value) { + if (!(value instanceof Number number)) { + return null; + } + double doubleValue = number.doubleValue(); + long longValue = number.longValue(); + return Double.compare(doubleValue, longValue) == 0 ? longValue : null; + } + + private String safeText(String value, String fallback) { + if (!StringUtils.hasText(value) || containsSecret(value)) { + return fallback; + } + String normalized = value.trim(); + return normalized.length() > 500 ? normalized.substring(0, 500) : normalized; + } + + private boolean containsSecret(String value) { + return StringUtils.hasText(value) && SECRET_TEXT_PATTERN.matcher(value).find(); + } + + private record StreamEvent(Long sequenceNo, String event, Map data) { + } + + private record StreamBatch(List events, Long maxSequenceNo) { + + private static StreamBatch empty(Long maxSequenceNo) { + return new StreamBatch(List.of(), maxSequenceNo == null ? 0L : maxSequenceNo); + } + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/controller/app/AppMuseEventsController.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/controller/app/AppMuseEventsController.java new file mode 100644 index 00000000..1401846b --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/controller/app/AppMuseEventsController.java @@ -0,0 +1,44 @@ +package cn.iocoder.muse.module.events.controller.app; + +import cn.iocoder.muse.module.events.application.stream.EventsStreamService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import static cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 用户端 Muse 统一事件流入口。 + */ +@Tag(name = "用户 APP - Muse Events") +@RestController +@RequestMapping("/muse") +public class AppMuseEventsController { + + @Resource + private EventsStreamService eventsStreamService; + + /** + * 建立统一事件流 SSE 连接。 + * + *

登录用户必须在进入 SSE 生命周期前读取,避免后台线程再访问请求安全上下文。

+ * + * @param apiVersion `X-API-Version` 合同版本 + * @param lastEventId SSE 恢复游标;空值表示首连,不 replay 历史 + * @return SSE 事件流骨架 + */ + @GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation(summary = "统一事件流 SSE") + public SseEmitter streamEvents(@RequestHeader(value = "X-API-Version", required = false) String apiVersion, + @RequestParam(value = "lastEventId", required = false) String lastEventId) { + return eventsStreamService.streamEvents(getLoginUserId(), apiVersion, lastEventId); + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/dataobject/UnifiedEventDO.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/dataobject/UnifiedEventDO.java new file mode 100644 index 00000000..80b4e6b8 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/dataobject/UnifiedEventDO.java @@ -0,0 +1,67 @@ +package cn.iocoder.muse.module.events.dal.dataobject; + +import cn.iocoder.muse.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.muse.module.events.dal.type.JsonbStringTypeHandler; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * Events 统一事件投影 DO。 + */ +@TableName(value = "muse_unified_event", autoResultMap = true) +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UnifiedEventDO extends TenantBaseDO { + + @TableId(type = IdType.AUTO) + private Long id; + + private String commandId; + + private String eventId; + + /** + * sequenceNo 由 PostgreSQL sequence/default 分配,应用层只在插入后回查读取。 + */ + private Long sequenceNo; + + private String eventType; + + private Long ownerUserId; + + private String sourceOwner; + + private String sourceType; + + private String sourceId; + + private String sourceRevision; + + private String resourceType; + + private String resourceId; + + /** + * 已脱敏的 OpenAPI 事件 payload 摘要,不保存 provider raw body、token 或授权头。 + */ + @TableField(typeHandler = JsonbStringTypeHandler.class) + private String payloadSummary; + + private String publishStatus; + + private String publishErrorCode; + + private LocalDateTime visibleFrom; + + private LocalDateTime emittedAt; + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/mysql/UnifiedEventMapper.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/mysql/UnifiedEventMapper.java new file mode 100644 index 00000000..37d553a7 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/mysql/UnifiedEventMapper.java @@ -0,0 +1,80 @@ +package cn.iocoder.muse.module.events.dal.mysql; + +import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.muse.module.events.application.publish.EventsPublishServiceImpl; +import cn.iocoder.muse.module.events.dal.dataobject.UnifiedEventDO; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Events 统一事件投影 Mapper。 + */ +@Mapper +public interface UnifiedEventMapper extends BaseMapperX { + + default UnifiedEventDO selectByTenantIdAndCommandId(Long tenantId, String commandId) { + return selectOne(new LambdaQueryWrapperX() + .eq(UnifiedEventDO::getTenantId, tenantId) + .eq(UnifiedEventDO::getCommandId, commandId)); + } + + default UnifiedEventDO selectByTenantIdAndSourceTuple(Long tenantId, String sourceOwner, String sourceType, + String sourceId, String sourceRevision, String eventType) { + return selectOne(new LambdaQueryWrapperX() + .eq(UnifiedEventDO::getTenantId, tenantId) + .eq(UnifiedEventDO::getSourceOwner, sourceOwner) + .eq(UnifiedEventDO::getSourceType, sourceType) + .eq(UnifiedEventDO::getSourceId, sourceId) + .eq(UnifiedEventDO::getSourceRevision, sourceRevision) + .eq(UnifiedEventDO::getEventType, eventType)); + } + + default List selectVisibleEventsForOwner(Long tenantId, Long ownerUserId, Long afterSequenceNo, + LocalDateTime now) { + return selectList(new LambdaQueryWrapperX() + .eq(UnifiedEventDO::getTenantId, tenantId) + .eq(UnifiedEventDO::getOwnerUserId, ownerUserId) + .eq(UnifiedEventDO::getPublishStatus, EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED) + .eq(UnifiedEventDO::getDeleted, false) + .le(UnifiedEventDO::getVisibleFrom, now) + .gt(UnifiedEventDO::getSequenceNo, afterSequenceNo) + .orderByAsc(UnifiedEventDO::getSequenceNo)); + } + + default Long selectMaxVisibleSequenceForOwner(Long tenantId, Long ownerUserId, LocalDateTime now) { + UnifiedEventDO latest = selectOne(new LambdaQueryWrapperX() + .eq(UnifiedEventDO::getTenantId, tenantId) + .eq(UnifiedEventDO::getOwnerUserId, ownerUserId) + .eq(UnifiedEventDO::getPublishStatus, EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED) + .eq(UnifiedEventDO::getDeleted, false) + .le(UnifiedEventDO::getVisibleFrom, now) + // 空 cursor 首连只冻结当前最大可见 sequence,不 replay 历史事件。 + .orderByDesc(UnifiedEventDO::getSequenceNo) + .last("LIMIT 1")); + return latest == null ? 0L : latest.getSequenceNo(); + } + + /** + * 首次写入统一事件;并发重复 commandId 或 source tuple 时不覆盖既有事件,只由服务层回查幂等结果。 + * + *

注意:这里不写 sequence_no 字段,必须由 V16 DDL 的 PostgreSQL sequence/default 分配。

+ */ + @Insert(""" + INSERT INTO muse_unified_event(command_id, event_id, event_type, owner_user_id, + source_owner, source_type, source_id, source_revision, + resource_type, resource_id, payload_summary, + publish_status, publish_error_code, visible_from, emitted_at, tenant_id) + VALUES (#{commandId}, #{eventId}, #{eventType}, #{ownerUserId}, + #{sourceOwner}, #{sourceType}, #{sourceId}, #{sourceRevision}, + #{resourceType}, #{resourceId}, + #{payloadSummary,typeHandler=cn.iocoder.muse.module.events.dal.type.JsonbStringTypeHandler}, + #{publishStatus}, #{publishErrorCode}, #{visibleFrom}, #{emittedAt}, #{tenantId}) + ON CONFLICT DO NOTHING + """) + int insertIgnore(UnifiedEventDO event); + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/type/JsonbStringTypeHandler.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/type/JsonbStringTypeHandler.java new file mode 100644 index 00000000..893ab291 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/dal/type/JsonbStringTypeHandler.java @@ -0,0 +1,48 @@ +package cn.iocoder.muse.module.events.dal.type; + +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; + +/** + * Events 模块本地 PostgreSQL JSONB 字符串 TypeHandler。 + * + *

统一事件只保存已经脱敏后的 JSON 摘要;这里用 JDBC OTHER 交给 PostgreSQL JSONB 解析, + * 避免把 JSON 字符串再次序列化成字符串字面量。

+ */ +public class JsonbStringTypeHandler extends BaseTypeHandler { + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) + throws SQLException { + ps.setObject(i, parameter, Types.OTHER); + } + + @Override + public String getNullableResult(ResultSet rs, String columnName) throws SQLException { + return readJson(rs.getObject(columnName)); + } + + @Override + public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + return readJson(rs.getObject(columnIndex)); + } + + @Override + public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + return readJson(cs.getObject(columnIndex)); + } + + private String readJson(Object value) { + if (value == null) { + return null; + } + return value.toString(); + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/domain/EventsCursor.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/domain/EventsCursor.java new file mode 100644 index 00000000..f6332285 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/domain/EventsCursor.java @@ -0,0 +1,63 @@ +package cn.iocoder.muse.module.events.domain; + +import org.springframework.util.StringUtils; + +/** + * Events SSE Last-Event-ID 游标。 + * + *

游标解析必须 fail-closed:非法输入只返回安全错误码,不能把原始 cursor 或解析异常透出到浏览器。

+ */ +public record EventsCursor(Long sequenceNo, boolean valid, String errorCode, String errorMessage) { + + private static final String PREFIX = "muse:"; + public static final String ERROR_CURSOR_INVALID = "EVENTS_CURSOR_INVALID"; + + /** + * 解析 Events SSE 游标。 + * + * @param rawCursor 浏览器传入的 lastEventId;空值表示首连,不 replay 历史 + * @return 解析结果;非法 cursor 由调用方发送 OpenAPI error event + */ + public static EventsCursor parse(String rawCursor) { + if (!StringUtils.hasText(rawCursor)) { + return new EventsCursor(null, true, null, null); + } + String cursor = rawCursor.trim(); + if (!cursor.startsWith(PREFIX)) { + return invalid(); + } + String sequenceText = cursor.substring(PREFIX.length()); + if (!StringUtils.hasText(sequenceText)) { + return invalid(); + } + try { + long sequenceNo = Long.parseLong(sequenceText); + if (sequenceNo < 0L) { + return invalid(); + } + return new EventsCursor(sequenceNo, true, null, null); + } catch (NumberFormatException ex) { + return invalid(); + } + } + + /** + * SSE id 使用全局统一格式,避免客户端把裸数字和其他 stream 的游标混用。 + */ + public static String toSseId(Long sequenceNo) { + return PREFIX + sequenceNo; + } + + public boolean isInitialConnection() { + return valid && sequenceNo == null; + } + + public boolean isValid() { + return valid; + } + + private static EventsCursor invalid() { + return new EventsCursor(null, false, ERROR_CURSOR_INVALID, "Invalid events cursor"); + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/domain/EventsPayloadSanitizer.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/domain/EventsPayloadSanitizer.java new file mode 100644 index 00000000..71c01b4c --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/domain/EventsPayloadSanitizer.java @@ -0,0 +1,96 @@ +package cn.iocoder.muse.module.events.domain; + +import cn.iocoder.muse.framework.common.util.json.JsonUtils; +import com.fasterxml.jackson.core.type.TypeReference; +import lombok.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.regex.Pattern; + +/** + * Events payload 脱敏器。 + * + *

source owner 可能来自外部模型、知识库或支付/授权链路;这里递归检查 key 与 value, + * 发现授权头、bearer token、secret 或 provider raw body 后,事件会被拒绝且只保存脱敏摘要。

+ */ +@Component +public class EventsPayloadSanitizer { + + private static final String REDACTED = "[REDACTED]"; + + private static final Pattern SECRET_KEY_PATTERN = Pattern.compile( + "(?i)(authorization|bearer|token|access[-_]?token|refresh[-_]?token|api[-_]?key|secret|password|credential|provider.*raw.*body|raw.*body)"); + + private static final Pattern SECRET_VALUE_PATTERN = Pattern.compile( + "(?i)(bearer\\s+[a-z0-9._\\-]+|authorization\\s*[:=]|api[-_]?key\\s*[:=]|secret\\s*[:=]|token\\s*[:=])"); + + public SanitizedPayload sanitize(Map payloadSummary) { + Map source = payloadSummary == null ? Map.of() : payloadSummary; + Detection detection = new Detection(); + Object sanitized = sanitizeValue(source, detection); + String json = JsonUtils.toJsonString(sanitized); + return new SanitizedPayload(json, detection.containsSecret); + } + + @SuppressWarnings("unchecked") + private Object sanitizeValue(Object value, Detection detection) { + if (value instanceof Map map) { + Map sanitizedMap = new LinkedHashMap<>(); + map.forEach((key, childValue) -> { + String normalizedKey = String.valueOf(key); + if (isSensitiveKey(normalizedKey)) { + detection.containsSecret = true; + sanitizedMap.put(normalizedKey, REDACTED); + return; + } + sanitizedMap.put(normalizedKey, sanitizeValue(childValue, detection)); + }); + return sanitizedMap; + } + if (value instanceof Collection collection) { + List sanitizedList = new ArrayList<>(collection.size()); + collection.forEach(item -> sanitizedList.add(sanitizeValue(item, detection))); + return sanitizedList; + } + if (value instanceof Object[] array) { + List sanitizedList = new ArrayList<>(array.length); + Arrays.stream(array).forEach(item -> sanitizedList.add(sanitizeValue(item, detection))); + return sanitizedList; + } + if (value instanceof String text && isSensitiveValue(text)) { + detection.containsSecret = true; + return REDACTED; + } + return value; + } + + private boolean isSensitiveKey(String key) { + return StringUtils.hasText(key) && SECRET_KEY_PATTERN.matcher(key).find(); + } + + private boolean isSensitiveValue(String value) { + return StringUtils.hasText(value) && SECRET_VALUE_PATTERN.matcher(value).find(); + } + + /** + * 解析已落库 JSON,供后续扩展测试或 stream 层复用。 + */ + public Map parseSanitizedJson(String json) { + Map parsed = JsonUtils.parseObjectQuietly(json, new TypeReference<>() { + }); + return parsed == null ? Map.of() : parsed; + } + + @Value + public static class SanitizedPayload { + String payloadJson; + boolean containsSecret; + } + + private static class Detection { + private boolean containsSecret; + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/framework/config/EventsStreamConfiguration.java b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/framework/config/EventsStreamConfiguration.java new file mode 100644 index 00000000..5569f286 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/main/java/cn/iocoder/muse/module/events/framework/config/EventsStreamConfiguration.java @@ -0,0 +1,34 @@ +package cn.iocoder.muse.module.events.framework.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +/** + * Events 统一 SSE 线程池配置。 + */ +@Configuration(proxyBeanMethods = false) +public class EventsStreamConfiguration { + + public static final String EVENTS_STREAM_EXECUTOR = "eventsStreamExecutor"; + + @Bean(EVENTS_STREAM_EXECUTOR) + public AsyncTaskExecutor eventsStreamExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(16); + executor.setKeepAliveSeconds(60); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("muse-events-sse-"); + executor.setWaitForTasksToCompleteOnShutdown(false); + executor.setAwaitTerminationSeconds(10); + // 线程池饱和时必须 fail-closed 返回 SSE error,不能退回调用线程造成请求线程阻塞。 + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.initialize(); + return executor; + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/application/publish/EventsPublishServiceTest.java b/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/application/publish/EventsPublishServiceTest.java new file mode 100644 index 00000000..24c183fc --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/application/publish/EventsPublishServiceTest.java @@ -0,0 +1,450 @@ +package cn.iocoder.muse.module.events.application.publish; + +import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishReqDTO; +import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO; +import cn.iocoder.muse.module.events.dal.dataobject.UnifiedEventDO; +import cn.iocoder.muse.module.events.dal.mysql.UnifiedEventMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.stubbing.Answer; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class EventsPublishServiceTest extends BaseMockitoUnitTest { + + @Mock + private UnifiedEventMapper unifiedEventMapper; + + private EventsPublishServiceImpl service; + private final List rows = new CopyOnWriteArrayList<>(); + private final AtomicLong idSequence = new AtomicLong(1L); + private final AtomicLong dbSequence = new AtomicLong(1L); + + @BeforeEach + void setUp() { + service = new EventsPublishServiceImpl(unifiedEventMapper); + // 这里模拟 PostgreSQL insert 后由 sequence/default 回填的结果,服务层只能回查读取,不能自己计算 sequenceNo。 + lenient().when(unifiedEventMapper.insertIgnore(any(UnifiedEventDO.class))).thenAnswer(insertWithDbAssignedSequence()); + lenient().when(unifiedEventMapper.selectByTenantIdAndCommandId(anyLong(), anyString())) + .thenAnswer(invocation -> findByCommand(invocation.getArgument(0), invocation.getArgument(1))); + lenient().when(unifiedEventMapper.selectByTenantIdAndSourceTuple(anyLong(), anyString(), anyString(), anyString(), + anyString(), anyString())) + .thenAnswer(invocation -> findBySourceTuple(invocation.getArgument(0), invocation.getArgument(1), + invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4), + invocation.getArgument(5))); + lenient().when(unifiedEventMapper.selectVisibleEventsForOwner(anyLong(), anyLong(), anyLong(), any(LocalDateTime.class))) + .thenAnswer(invocation -> rows.stream() + .filter(row -> row.getTenantId().equals(invocation.getArgument(0)) + && row.getOwnerUserId().equals(invocation.getArgument(1)) + && row.getSequenceNo() > invocation.getArgument(2) + && Boolean.FALSE.equals(row.getDeleted()) + && EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED.equals(row.getPublishStatus()) + && !row.getVisibleFrom().isAfter(invocation.getArgument(3))) + .sorted((left, right) -> Long.compare(left.getSequenceNo(), right.getSequenceNo())) + .toList()); + } + + @Test + void should_publishAcceptedEvent_when_payloadMatchesOpenApiSchema() { + EventsPublishRespDTO resp = service.publish(chunkReq("cmd-1", "rev-1", Map.of( + "content", "hello", + "sequenceNo", 1 + ))); + + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED, resp.getPublishStatus()); + assertFalse(resp.getDuplicate()); + assertNotNull(resp.getEventId()); + assertNotNull(resp.getSequenceNo()); + UnifiedEventDO row = rows.get(0); + assertEquals("cmd-1", row.getCommandId()); + assertEquals("chunk", row.getEventType()); + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED, row.getPublishStatus()); + assertNull(row.getPublishErrorCode()); + } + + @Test + void should_acceptOpenApiStringFields_when_stringIsEmpty() { + EventsPublishRespDTO chunkResp = service.publish(chunkReq("cmd-empty-chunk", "rev-empty-chunk", Map.of( + "content", "", + "sequenceNo", 1 + ))); + EventsPublishRespDTO errorResp = service.publish(eventReq("cmd-empty-error", "rev-empty-error", "error", + Map.of( + "code", "", + "message", "" + ))); + EventsPublishRespDTO notificationResp = service.publish(eventReq("cmd-empty-notification", + "rev-empty-notification", "notification", Map.of( + "type", "quota_alert", + "message", "" + ))); + + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED, chunkResp.getPublishStatus()); + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED, errorResp.getPublishStatus()); + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED, notificationResp.getPublishStatus()); + } + + @Test + void should_acceptDoneEvent_when_taskIdAndSuggestionIdAreLongOrInteger() { + EventsPublishRespDTO longResp = service.publish(eventReq("cmd-done-long", "rev-done-long", "done", Map.of( + "taskId", 1001L, + "suggestionId", 2001L + ))); + EventsPublishRespDTO integerResp = service.publish(eventReq("cmd-done-int", "rev-done-int", "done", Map.of( + "taskId", 1002, + "suggestionId", 2002 + ))); + + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED, longResp.getPublishStatus()); + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED, integerResp.getPublishStatus()); + assertEquals("done", rows.get(0).getEventType()); + assertEquals("done", rows.get(1).getEventType()); + } + + @Test + void should_rejectDoneEvent_when_taskIdOrSuggestionIdIsString() { + EventsPublishRespDTO taskIdResp = service.publish(eventReq("cmd-done-string-task", "rev-done-string-task", + "done", Map.of( + "taskId", "1001", + "suggestionId", 2001L + ))); + EventsPublishRespDTO suggestionIdResp = service.publish(eventReq("cmd-done-string-suggestion", + "rev-done-string-suggestion", "done", Map.of( + "taskId", 1002L, + "suggestionId", "2002" + ))); + + assertSchemaMismatchRejectedAndInvisible(taskIdResp); + assertSchemaMismatchRejectedAndInvisible(suggestionIdResp); + } + + @Test + void should_validateQualityCheckScoreRange() { + EventsPublishRespDTO validResp = service.publish(eventReq("cmd-quality-valid", "rev-quality-valid", + "quality_check", Map.of( + "dimension", "readability", + "score", 0.8D, + "passed", true + ))); + EventsPublishRespDTO lowResp = service.publish(eventReq("cmd-quality-low", "rev-quality-low", + "quality_check", Map.of( + "dimension", "readability", + "score", -0.1D, + "passed", false + ))); + EventsPublishRespDTO highResp = service.publish(eventReq("cmd-quality-high", "rev-quality-high", + "quality_check", Map.of( + "dimension", "readability", + "score", 1.1D, + "passed", true + ))); + + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED, validResp.getPublishStatus()); + assertSchemaMismatchRejectedAndInvisible(lowResp); + assertSchemaMismatchRejectedAndInvisible(highResp); + } + + @Test + void should_rejectChunkEvent_when_sequenceNoIsZeroOrNegative() { + EventsPublishRespDTO zeroResp = service.publish(chunkReq("cmd-chunk-zero", "rev-chunk-zero", Map.of( + "content", "hello", + "sequenceNo", 0 + ))); + EventsPublishRespDTO negativeResp = service.publish(chunkReq("cmd-chunk-negative", "rev-chunk-negative", Map.of( + "content", "hello", + "sequenceNo", -1 + ))); + + assertSchemaMismatchRejectedAndInvisible(zeroResp); + assertSchemaMismatchRejectedAndInvisible(negativeResp); + } + + @Test + void should_validateNotificationTypeEnum() { + EventsPublishRespDTO validResp = service.publish(eventReq("cmd-notification-valid", "rev-notification-valid", + "notification", Map.of( + "type", "source_status_change", + "message", "source changed" + ))); + EventsPublishRespDTO unknownResp = service.publish(eventReq("cmd-notification-unknown", + "rev-notification-unknown", "notification", Map.of( + "type", "unknown_type", + "message", "unknown" + ))); + + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED, validResp.getPublishStatus()); + assertSchemaMismatchRejectedAndInvisible(unknownResp); + } + + @Test + void should_returnSameEvent_when_duplicateCommandPublished() { + EventsPublishRespDTO first = service.publish(chunkReq("cmd-dup", "rev-1", Map.of( + "content", "first", + "sequenceNo", 1 + ))); + + EventsPublishRespDTO second = service.publish(chunkReq("cmd-dup", "rev-2", Map.of( + "content", "ignored by idempotency", + "sequenceNo", 2 + ))); + + assertEquals(first.getEventId(), second.getEventId()); + assertEquals(first.getSequenceNo(), second.getSequenceNo()); + assertTrue(second.getDuplicate()); + assertEquals(1, rows.size()); + } + + @Test + void should_rejectPayload_when_containsSecret() { + EventsPublishRespDTO resp = service.publish(chunkReq("cmd-secret", "rev-secret", Map.of( + "content", "hello", + "sequenceNo", 1, + "authorization", "Bearer abc" + ))); + + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_REJECTED, resp.getPublishStatus()); + assertEquals(EventsPublishServiceImpl.ERROR_PAYLOAD_CONTAINS_SECRET, resp.getPublishErrorCode()); + UnifiedEventDO row = rows.get(0); + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_REJECTED, row.getPublishStatus()); + assertEquals(EventsPublishServiceImpl.ERROR_PAYLOAD_CONTAINS_SECRET, row.getPublishErrorCode()); + } + + @Test + void should_rejectAndHidePayload_when_containsStandaloneBearerKey() { + EventsPublishRespDTO resp = service.publish(chunkReq("cmd-bearer-key", "rev-bearer-key", Map.of( + "content", "hello", + "sequenceNo", 1, + "bearer", "abc" + ))); + + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_REJECTED, resp.getPublishStatus()); + assertEquals(EventsPublishServiceImpl.ERROR_PAYLOAD_CONTAINS_SECRET, resp.getPublishErrorCode()); + List visibleRows = service.listVisibleEvents(1L, 10L, 0L, LocalDateTime.now().plusSeconds(1)); + assertTrue(visibleRows.stream() + .noneMatch(row -> row.getEventId().equals(resp.getEventId()))); + assertFalse(rows.get(0).getPayloadSummary().contains("abc")); + } + + @Test + void should_rejectPayload_when_eventTypeNotDeclared() { + EventsPublishReqDTO req = chunkReq("cmd-unknown", "rev-unknown", Map.of( + "content", "hello", + "sequenceNo", 1 + )); + req.setEventType("custom_event"); + + EventsPublishRespDTO resp = service.publish(req); + + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_REJECTED, resp.getPublishStatus()); + assertEquals(EventsPublishServiceImpl.ERROR_EVENT_TYPE_NOT_DECLARED, resp.getPublishErrorCode()); + assertEquals("error", rows.get(0).getEventType()); + } + + @Test + void should_notExposeRejectedEventToStreamQuery() { + service.publish(chunkReq("cmd-secret", "rev-secret", Map.of( + "content", "hello", + "sequenceNo", 1, + "token", "sensitive" + ))); + + List visibleRows = service.listVisibleEvents(1L, 10L, 0L, LocalDateTime.now().plusSeconds(1)); + + assertTrue(visibleRows.isEmpty()); + } + + @Test + void should_returnSameEvent_when_sourceOwnerReplaysOutboxFact() { + EventsPublishRespDTO first = service.publish(chunkReq("cmd-first", "rev-replay", Map.of( + "content", "hello", + "sequenceNo", 1 + ))); + + EventsPublishRespDTO replay = service.publish(chunkReq("cmd-replay", "rev-replay", Map.of( + "content", "same outbox fact", + "sequenceNo", 1 + ))); + + assertEquals(first.getEventId(), replay.getEventId()); + assertEquals(first.getSequenceNo(), replay.getSequenceNo()); + assertTrue(replay.getDuplicate()); + assertEquals(1, rows.size()); + } + + @Test + void should_notExposeBlockedEvent_when_publishValidationFailsAfterRetry() { + UnifiedEventDO blocked = baseRow("cmd-blocked", "rev-blocked", "chunk"); + blocked.setPublishStatus(EventsPublishServiceImpl.PUBLISH_STATUS_BLOCKED); + blocked.setPublishErrorCode("retry_dead_letter"); + rows.add(blocked); + + List visibleRows = service.listVisibleEvents(1L, 10L, 0L, LocalDateTime.now().plusSeconds(1)); + + assertTrue(visibleRows.isEmpty()); + } + + @Test + void should_returnSameEvent_when_sourceRevisionIsNullAndReplayed() { + EventsPublishReqDTO firstReq = chunkReq("cmd-null-rev-1", null, Map.of( + "content", "hello", + "sequenceNo", 1 + )); + EventsPublishRespDTO first = service.publish(firstReq); + + EventsPublishReqDTO replayReq = chunkReq("cmd-null-rev-2", null, Map.of( + "content", "hello again", + "sequenceNo", 1 + )); + EventsPublishRespDTO replay = service.publish(replayReq); + + assertEquals(first.getEventId(), replay.getEventId()); + assertEquals(first.getSequenceNo(), replay.getSequenceNo()); + assertEquals(EventsPublishServiceImpl.SOURCE_REVISION_NONE, rows.get(0).getSourceRevision()); + } + + @Test + void should_allocateMonotonicSequenceNo_whenPublishConcurrentEvents() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(4); + List> tasks = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + int index = i; + tasks.add(() -> service.publish(chunkReq("cmd-concurrent-" + index, "rev-" + index, Map.of( + "content", "chunk-" + index, + "sequenceNo", index + 1 + )))); + } + + List responses; + try { + responses = executor.invokeAll(tasks).stream() + .map(future -> { + try { + return future.get(3, TimeUnit.SECONDS); + } catch (Exception ex) { + throw new CompletionException(ex); + } + }) + .toList(); + } finally { + executor.shutdownNow(); + } + + List sequenceNos = responses.stream() + .map(EventsPublishRespDTO::getSequenceNo) + .sorted() + .toList(); + assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L), sequenceNos); + } + + @Test + void should_failClosed_whenInsertIgnoredAndStoredEventCannotBeFound() { + when(unifiedEventMapper.insertIgnore(any(UnifiedEventDO.class))).thenReturn(0); + when(unifiedEventMapper.selectByTenantIdAndCommandId(anyLong(), anyString())).thenReturn(null); + when(unifiedEventMapper.selectByTenantIdAndSourceTuple(anyLong(), anyString(), anyString(), anyString(), + anyString(), anyString())).thenReturn(null); + + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> service.publish( + chunkReq("cmd-missing-after-ignore", "rev-missing-after-ignore", Map.of( + "content", "lost", + "sequenceNo", 1 + )))); + + assertTrue(exception.getMessage().contains("Events 发布幂等回查失败")); + assertTrue(rows.isEmpty(), "insertIgnore 冲突且回查为空时,不能返回未落库的 eventId/sequenceNo"); + } + + private Answer insertWithDbAssignedSequence() { + return invocation -> { + UnifiedEventDO row = invocation.getArgument(0); + row.setId(idSequence.getAndIncrement()); + row.setSequenceNo(dbSequence.getAndIncrement()); + rows.add(row); + return 1; + }; + } + + private UnifiedEventDO findByCommand(Long tenantId, String commandId) { + return rows.stream() + .filter(row -> row.getTenantId().equals(tenantId) && row.getCommandId().equals(commandId)) + .findFirst() + .orElse(null); + } + + private UnifiedEventDO findBySourceTuple(Long tenantId, String sourceOwner, String sourceType, String sourceId, + String sourceRevision, String eventType) { + return rows.stream() + .filter(row -> row.getTenantId().equals(tenantId) + && row.getSourceOwner().equals(sourceOwner) + && row.getSourceType().equals(sourceType) + && row.getSourceId().equals(sourceId) + && row.getSourceRevision().equals(sourceRevision) + && row.getEventType().equals(eventType)) + .findFirst() + .orElse(null); + } + + private EventsPublishReqDTO chunkReq(String commandId, String sourceRevision, Map payload) { + EventsPublishReqDTO req = new EventsPublishReqDTO(); + req.setCommandId(commandId); + req.setTenantId(1L); + req.setOwnerUserId(10L); + req.setSourceOwner("ai"); + req.setSourceType("task"); + req.setSourceId("task-1"); + req.setSourceRevision(sourceRevision); + req.setEventType("chunk"); + req.setResourceType("suggestion"); + req.setResourceId("suggestion-1"); + req.setPayloadSummary(payload); + req.setEmittedAt(LocalDateTime.now()); + return req; + } + + private EventsPublishReqDTO eventReq(String commandId, String sourceRevision, String eventType, + Map payload) { + EventsPublishReqDTO req = chunkReq(commandId, sourceRevision, payload); + req.setEventType(eventType); + return req; + } + + private void assertSchemaMismatchRejectedAndInvisible(EventsPublishRespDTO resp) { + assertEquals(EventsPublishServiceImpl.PUBLISH_STATUS_REJECTED, resp.getPublishStatus()); + assertEquals(EventsPublishServiceImpl.ERROR_PAYLOAD_SCHEMA_MISMATCH, resp.getPublishErrorCode()); + List visibleRows = service.listVisibleEvents(1L, 10L, 0L, LocalDateTime.now().plusSeconds(1)); + assertTrue(visibleRows.stream() + .noneMatch(row -> row.getEventId().equals(resp.getEventId()))); + } + + private UnifiedEventDO baseRow(String commandId, String sourceRevision, String eventType) { + UnifiedEventDO row = new UnifiedEventDO(); + row.setTenantId(1L); + row.setCommandId(commandId); + row.setEventId("evt-" + commandId); + row.setSequenceNo(dbSequence.getAndIncrement()); + row.setEventType(eventType); + row.setOwnerUserId(10L); + row.setSourceOwner("ai"); + row.setSourceType("task"); + row.setSourceId("task-1"); + row.setSourceRevision(sourceRevision); + row.setResourceType("suggestion"); + row.setResourceId("suggestion-1"); + row.setPayloadSummary("{}"); + row.setVisibleFrom(LocalDateTime.now()); + row.setEmittedAt(LocalDateTime.now()); + row.setDeleted(false); + return row; + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/application/stream/EventsStreamServiceTest.java b/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/application/stream/EventsStreamServiceTest.java new file mode 100644 index 00000000..6edbd10e --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/application/stream/EventsStreamServiceTest.java @@ -0,0 +1,324 @@ +package cn.iocoder.muse.module.events.application.stream; + +import cn.iocoder.muse.framework.common.util.json.JsonUtils; +import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.muse.module.events.dal.dataobject.UnifiedEventDO; +import cn.iocoder.muse.module.events.dal.mysql.UnifiedEventMapper; +import cn.iocoder.muse.module.events.domain.EventsPayloadSanitizer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static cn.iocoder.muse.module.events.application.publish.EventsPublishServiceImpl.PUBLISH_STATUS_ACCEPTED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Events 统一 SSE stream 应用服务测试。 + */ +class EventsStreamServiceTest { + + @Mock + private UnifiedEventMapper unifiedEventMapper; + + private AutoCloseable mocks; + private EventsStreamServiceImpl streamService; + private RecordingAsyncTaskExecutor executor; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + executor = new RecordingAsyncTaskExecutor(false, false); + streamService = new EventsStreamServiceImpl(unifiedEventMapper, new EventsPayloadSanitizer(), executor); + TenantContextHolder.setTenantId(100L); + } + + @AfterEach + void tearDown() throws Exception { + TenantContextHolder.clear(); + mocks.close(); + } + + @Test + void should_replayAcceptedEventsAfterCursor_forCurrentTenantAndOwner() { + when(unifiedEventMapper.selectVisibleEventsForOwner(eq(100L), eq(10L), eq(2L), any(LocalDateTime.class))) + .thenReturn(List.of( + event(3L, 100L, 10L, "chunk", Map.of("content", "第一段", "sequenceNo", 1)), + event(4L, 100L, 10L, "notification", Map.of("type", "quota_alert", "message", "额度提醒", + "resourceRef", Map.of("resourceType", "quota", "resourceId", 7L, + "ignored", "不能透出"))))); + + SseEmitter emitter = streamService.streamEvents(10L, "1", "muse:2"); + + String text = sentText(emitter); + assertTrue(text.contains("id:muse:3")); + assertTrue(text.contains("event:chunk")); + assertTrue(text.contains("id:muse:4")); + assertTrue(text.contains("event:notification")); + assertEquals(List.of( + Map.of("content", "第一段", "sequenceNo", 1L), + Map.of("type", "quota_alert", "message", "额度提醒", + "resourceRef", Map.of("resourceType", "quota", "resourceId", 7L))), sentMaps(emitter)); + assertEquals(1, executor.submitCount); + } + + @Test + void should_notReplayHistoricalEvents_whenLastEventIdMissing() { + when(unifiedEventMapper.selectMaxVisibleSequenceForOwner(eq(100L), eq(10L), any(LocalDateTime.class))) + .thenReturn(9L); + + SseEmitter emitter = streamService.streamEvents(10L, "1", null); + + assertTrue(sentText(emitter).contains("heartbeat")); + assertTrue(sentMaps(emitter).isEmpty(), "空 cursor 首连只能发 heartbeat,不能 replay 历史事件"); + verify(unifiedEventMapper, never()).selectVisibleEventsForOwner(anyLong(), anyLong(), anyLong(), any()); + assertEquals(1, executor.submitCount); + } + + @Test + void should_notReplayCrossTenantOrCrossOwnerEvents() { + List rows = List.of( + event(2L, 100L, 10L, "chunk", Map.of("content", "visible", "sequenceNo", 1)), + event(3L, 200L, 10L, "chunk", Map.of("content", "cross tenant", "sequenceNo", 2)), + event(4L, 100L, 20L, "chunk", Map.of("content", "cross owner", "sequenceNo", 3))); + when(unifiedEventMapper.selectVisibleEventsForOwner(anyLong(), anyLong(), eq(1L), any(LocalDateTime.class))) + .thenAnswer(invocation -> rows.stream() + .filter(row -> row.getTenantId().equals(invocation.getArgument(0)) + && row.getOwnerUserId().equals(invocation.getArgument(1))) + .toList()); + + SseEmitter emitter = streamService.streamEvents(10L, "1", "muse:1"); + + String payloadJson = JsonUtils.toJsonString(sentMaps(emitter)); + assertTrue(payloadJson.contains("visible")); + assertFalse(payloadJson.contains("cross tenant")); + assertFalse(payloadJson.contains("cross owner")); + verify(unifiedEventMapper).selectVisibleEventsForOwner(eq(100L), eq(10L), eq(1L), any(LocalDateTime.class)); + } + + @Test + void should_sendHeartbeatOnly_whenNoVisibleEvents() { + when(unifiedEventMapper.selectVisibleEventsForOwner(eq(100L), eq(10L), eq(9L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + SseEmitter emitter = streamService.streamEvents(10L, "1", "muse:9"); + + assertTrue(sentText(emitter).contains("heartbeat")); + assertTrue(sentMaps(emitter).isEmpty(), "无事件时只能发送 SSE comment,不能发送假 event"); + assertEquals(1, executor.submitCount); + } + + @Test + void should_emitErrorEventAndComplete_whenCursorInvalid() { + SseEmitter emitter = streamService.streamEvents(10L, "1", "muse:not-a-number"); + + assertTrue(sentText(emitter).contains("event:error")); + assertEquals("EVENTS_CURSOR_INVALID", sentMaps(emitter).get(0).get("code")); + assertTrue(isComplete(emitter)); + verifyNoInteractions(unifiedEventMapper); + assertEquals(0, executor.submitCount); + } + + @Test + void should_emitErrorEventAndComplete_whenApiVersionUnsupported() { + SseEmitter emitter = streamService.streamEvents(10L, "2", null); + + assertTrue(sentText(emitter).contains("event:error")); + assertEquals("EVENTS_API_VERSION_UNSUPPORTED", sentMaps(emitter).get(0).get("code")); + assertTrue(isComplete(emitter)); + verifyNoInteractions(unifiedEventMapper); + assertEquals(0, executor.submitCount); + } + + @Test + void should_completeWithError_whenExecutorRejectsPolling() { + executor = new RecordingAsyncTaskExecutor(false, true); + streamService = new EventsStreamServiceImpl(unifiedEventMapper, new EventsPayloadSanitizer(), executor); + when(unifiedEventMapper.selectVisibleEventsForOwner(eq(100L), eq(10L), eq(9L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + SseEmitter emitter = streamService.streamEvents(10L, "1", "muse:9"); + + assertTrue(sentText(emitter).contains("event:error")); + assertEquals("EVENTS_STREAM_UNAVAILABLE", sentMaps(emitter).get(0).get("code")); + assertTrue(isComplete(emitter)); + assertNull(ReflectionTestUtils.getField(emitter, "failure"), + "线程池拒绝发生在 SSE 生命周期内,应发送合同内 error event 后 complete,而不是暴露异常对象"); + } + + @Test + void should_cancelPollingFuture_whenEmitterCompletes() { + when(unifiedEventMapper.selectVisibleEventsForOwner(eq(100L), eq(10L), eq(9L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + SseEmitter emitter = streamService.streamEvents(10L, "1", "muse:9"); + Object completionCallback = ReflectionTestUtils.getField(emitter, "completionCallback"); + ReflectionTestUtils.invokeMethod(completionCallback, "run"); + + assertTrue(executor.lastFuture.cancelled); + } + + @Test + void should_notLeakSecretFields_whenStreamingErrorEvent() { + executor = new RecordingAsyncTaskExecutor(true, false); + streamService = new EventsStreamServiceImpl(unifiedEventMapper, new EventsPayloadSanitizer(), executor); + when(unifiedEventMapper.selectVisibleEventsForOwner(eq(100L), eq(10L), eq(9L), any(LocalDateTime.class))) + .thenReturn(List.of()) + .thenThrow(new IllegalStateException("Authorization: Bearer secret-token provider raw body")); + + SseEmitter emitter = streamService.streamEvents(10L, "1", "muse:9"); + + String text = sentText(emitter) + JsonUtils.toJsonString(sentMaps(emitter)); + assertTrue(text.contains("event:error")); + assertTrue(text.contains("EVENTS_STREAM_UNAVAILABLE")); + assertFalse(text.contains("Bearer")); + assertFalse(text.contains("secret-token")); + assertFalse(text.contains("provider raw body")); + assertTrue(isComplete(emitter)); + } + + private static UnifiedEventDO event(Long sequenceNo, Long tenantId, Long ownerUserId, String eventType, + Map payload) { + UnifiedEventDO event = new UnifiedEventDO(); + event.setTenantId(tenantId); + event.setOwnerUserId(ownerUserId); + event.setSequenceNo(sequenceNo); + event.setEventType(eventType); + event.setPayloadSummary(JsonUtils.toJsonString(payload)); + event.setPublishStatus(PUBLISH_STATUS_ACCEPTED); + event.setDeleted(false); + event.setVisibleFrom(LocalDateTime.now().minusSeconds(1)); + return event; + } + + private static List> sentMaps(SseEmitter emitter) { + List> maps = new ArrayList<>(); + for (Object data : sentData(emitter)) { + if (data instanceof Map map) { + @SuppressWarnings("unchecked") + Map casted = (Map) map; + maps.add(casted); + } + } + return maps; + } + + private static String sentText(SseEmitter emitter) { + return String.join("\n", sentData(emitter).stream().map(String::valueOf).toList()); + } + + private static List sentData(SseEmitter emitter) { + Set attempts = (Set) ReflectionTestUtils.getField(emitter, "earlySendAttempts"); + if (attempts == null) { + return List.of(); + } + return attempts.stream().map(EventsStreamServiceTest::dataFrom).toList(); + } + + private static Object dataFrom(Object dataWithMediaType) { + try { + Field field = dataWithMediaType.getClass().getDeclaredField("data"); + field.setAccessible(true); + return field.get(dataWithMediaType); + } catch (ReflectiveOperationException ex) { + throw new AssertionError("无法读取 SseEmitter earlySendAttempts", ex); + } + } + + private static boolean isComplete(SseEmitter emitter) { + Boolean complete = (Boolean) ReflectionTestUtils.getField(emitter, "complete"); + return Boolean.TRUE.equals(complete); + } + + private static class RecordingAsyncTaskExecutor implements AsyncTaskExecutor { + + private final boolean runImmediately; + private final boolean reject; + private int submitCount; + private Runnable submittedRunnable; + private RecordingFuture lastFuture; + + private RecordingAsyncTaskExecutor(boolean runImmediately, boolean reject) { + this.runImmediately = runImmediately; + this.reject = reject; + } + + @Override + public void execute(Runnable task) { + submit(task); + } + + @Override + public Future submit(Runnable task) { + if (reject) { + throw new RejectedExecutionException("events stream executor full"); + } + submitCount++; + submittedRunnable = task; + lastFuture = new RecordingFuture(); + if (runImmediately) { + task.run(); + lastFuture.done = true; + } + return lastFuture; + } + + @Override + public Future submit(Callable task) { + throw new UnsupportedOperationException("Events stream 测试只需要 Runnable 提交路径"); + } + } + + private static class RecordingFuture implements Future { + + private boolean cancelled; + private boolean done; + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + cancelled = true; + done = true; + return true; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public boolean isDone() { + return done; + } + + @Override + public Object get() throws InterruptedException, ExecutionException { + return null; + } + + @Override + public Object get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + return null; + } + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/dal/mysql/UnifiedEventMapperTest.java b/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/dal/mysql/UnifiedEventMapperTest.java new file mode 100644 index 00000000..08fc1433 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/dal/mysql/UnifiedEventMapperTest.java @@ -0,0 +1,110 @@ +package cn.iocoder.muse.module.events.dal.mysql; + +import cn.iocoder.muse.module.events.dal.dataobject.UnifiedEventDO; +import cn.iocoder.muse.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class UnifiedEventMapperTest { + + @Test + void should_mapUnifiedEventDoToV16TableWithJsonbHandler() { + TableName tableName = UnifiedEventDO.class.getAnnotation(TableName.class); + + assertNotNull(tableName); + assertEquals("muse_unified_event", tableName.value()); + assertTrue(tableName.autoResultMap(), "payload_summary 是 JSONB,需要 autoResultMap 启用本地 TypeHandler"); + } + + @Test + void should_keepMapperAndServiceAwayFromApplicationSequenceAllocation() throws IOException { + String source = readSource("src/main/java/cn/iocoder/muse/module/events/application/publish/EventsPublishServiceImpl.java") + + readSource("src/main/java/cn/iocoder/muse/module/events/dal/mysql/UnifiedEventMapper.java"); + + assertFalse(source.contains("max(sequence_no)+1")); + assertFalse(source.contains("max(sequence_no) + 1")); + assertFalse(source.contains("AtomicLong")); + assertFalse(source.contains("sequenceNo++")); + } + + @Test + void should_normalizeNullSourceRevisionInService() throws IOException { + String source = readSource("src/main/java/cn/iocoder/muse/module/events/application/publish/EventsPublishServiceImpl.java"); + + assertTrue(source.contains("SOURCE_REVISION_NONE")); + assertTrue(source.contains("__none__")); + } + + @Test + void should_filterRejectedAndBlockedRowsInVisibilityQuery() throws IOException { + String source = readSource("src/main/java/cn/iocoder/muse/module/events/dal/mysql/UnifiedEventMapper.java"); + String visibleQuery = source.substring(source.indexOf("selectVisibleEventsForOwner")); + + assertTrue(visibleQuery.contains("PUBLISH_STATUS_ACCEPTED")); + assertFalse(visibleQuery.contains("PUBLISH_STATUS_REJECTED")); + assertFalse(visibleQuery.contains("PUBLISH_STATUS_BLOCKED")); + assertTrue(visibleQuery.contains("getDeleted")); + assertTrue(visibleQuery.contains("getVisibleFrom")); + } + + @Test + void should_keepV16ColumnsCompatibleWithTenantBaseDoMapping() throws IOException { + String ddl = readRepositorySource("sql/muse/V16__extend_events_sse_schema.sql") + .toLowerCase() + .replaceAll("\\s+", " "); + + assertEquals(TenantBaseDO.class, UnifiedEventDO.class.getSuperclass(), + "UnifiedEventDO 继承 TenantBaseDO 时,V16 DDL 必须补齐 BaseDO 默认映射列"); + assertTrue(ddl.contains("creator varchar(64) not null default ''"), + "muse_unified_event 必须包含 BaseDO.creator 映射列"); + assertTrue(ddl.contains("updater varchar(64) not null default ''"), + "muse_unified_event 必须包含 BaseDO.updater 映射列"); + } + + private String readSource(String relativePath) throws IOException { + Path moduleRoot = findModuleRoot(); + return Files.exists(moduleRoot.resolve(relativePath)) ? Files.readString(moduleRoot.resolve(relativePath)) : ""; + } + + private String readRepositorySource(String relativePath) throws IOException { + Path repositoryRoot = findRepositoryRoot(); + return Files.exists(repositoryRoot.resolve(relativePath)) ? Files.readString(repositoryRoot.resolve(relativePath)) : ""; + } + + private Path findModuleRoot() { + Path current = Path.of(System.getProperty("user.dir")); + while (current != null) { + if (Files.exists(current.resolve("muse-module-events-server/pom.xml"))) { + return current.resolve("muse-module-events-server"); + } + if (Files.exists(current.resolve("pom.xml")) + && current.getFileName() != null + && "muse-module-events-server".equals(current.getFileName().toString())) { + return current; + } + current = current.getParent(); + } + throw new IllegalStateException("无法定位 muse-module-events-server 模块根目录"); + } + + private Path findRepositoryRoot() { + Path current = Path.of(System.getProperty("user.dir")); + while (current != null) { + if (Files.exists(current.resolve("sql/muse/V16__extend_events_sse_schema.sql"))) { + return current; + } + if (Files.exists(current.resolve("muse-cloud/sql/muse/V16__extend_events_sse_schema.sql"))) { + return current.resolve("muse-cloud"); + } + current = current.getParent(); + } + throw new IllegalStateException("无法定位 muse-cloud 根目录"); + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/domain/EventsCursorTest.java b/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/domain/EventsCursorTest.java new file mode 100644 index 00000000..a8035949 --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/domain/EventsCursorTest.java @@ -0,0 +1,57 @@ +package cn.iocoder.muse.module.events.domain; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Events SSE 游标解析测试。 + */ +class EventsCursorTest { + + @Test + void should_acceptEmptyCursor() { + EventsCursor cursor = EventsCursor.parse(null); + EventsCursor blankCursor = EventsCursor.parse(" "); + + assertTrue(cursor.isValid()); + assertTrue(cursor.isInitialConnection()); + assertNull(cursor.sequenceNo()); + assertTrue(blankCursor.isValid()); + assertTrue(blankCursor.isInitialConnection()); + } + + @Test + void should_acceptMuseSequenceCursor() { + EventsCursor cursor = EventsCursor.parse("muse:123"); + + assertTrue(cursor.isValid()); + assertFalse(cursor.isInitialConnection()); + assertEquals(123L, cursor.sequenceNo()); + } + + @Test + void should_rejectNegativeSequenceCursor() { + EventsCursor cursor = EventsCursor.parse("muse:-1"); + + assertFalse(cursor.isValid()); + assertEquals("EVENTS_CURSOR_INVALID", cursor.errorCode()); + } + + @Test + void should_rejectNonNumericSequenceCursor() { + EventsCursor cursor = EventsCursor.parse("muse:not-a-number"); + + assertFalse(cursor.isValid()); + assertEquals("EVENTS_CURSOR_INVALID", cursor.errorCode()); + } + + @Test + void should_rejectWrongPrefixCursor() { + EventsCursor cursor = EventsCursor.parse("event:123"); + + assertFalse(cursor.isValid()); + assertEquals("EVENTS_CURSOR_INVALID", cursor.errorCode()); + } + +} diff --git a/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/framework/config/EventsStreamConfigurationTest.java b/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/framework/config/EventsStreamConfigurationTest.java new file mode 100644 index 00000000..919722de --- /dev/null +++ b/muse-cloud/muse-module-events/muse-module-events-server/src/test/java/cn/iocoder/muse/module/events/framework/config/EventsStreamConfigurationTest.java @@ -0,0 +1,32 @@ +package cn.iocoder.muse.module.events.framework.config; + +import org.junit.jupiter.api.Test; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Events SSE 线程池配置测试。 + */ +class EventsStreamConfigurationTest { + + @Test + void should_useBoundedExecutorWithAbortPolicy() { + EventsStreamConfiguration configuration = new EventsStreamConfiguration(); + + AsyncTaskExecutor taskExecutor = configuration.eventsStreamExecutor(); + + assertInstanceOf(ThreadPoolTaskExecutor.class, taskExecutor); + ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) taskExecutor; + assertEquals(4, executor.getCorePoolSize()); + assertEquals(16, executor.getMaxPoolSize()); + assertEquals(100, executor.getThreadPoolExecutor().getQueue().remainingCapacity()); + assertInstanceOf(ThreadPoolExecutor.AbortPolicy.class, + executor.getThreadPoolExecutor().getRejectedExecutionHandler()); + executor.shutdown(); + } + +} diff --git a/muse-cloud/muse-module-events/pom.xml b/muse-cloud/muse-module-events/pom.xml new file mode 100644 index 00000000..46c53ca1 --- /dev/null +++ b/muse-cloud/muse-module-events/pom.xml @@ -0,0 +1,19 @@ + + + + cn.iocoder.cloud + muse + ${revision} + + 4.0.0 + + muse-module-events-api + muse-module-events-server + + muse-module-events + pom + ${project.artifactId} + events 模块,负责统一事件流和跨 owner 事件发布契约。 + diff --git a/muse-cloud/muse-server/pom.xml b/muse-cloud/muse-server/pom.xml index fc2c5450..4119a04d 100644 --- a/muse-cloud/muse-server/pom.xml +++ b/muse-cloud/muse-server/pom.xml @@ -59,6 +59,11 @@ muse-module-meta-server ${revision} + + cn.iocoder.cloud + muse-module-events-server + ${revision} + cn.iocoder.cloud muse-module-knowledge-server diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsFlywayMigrationIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsFlywayMigrationIT.java new file mode 100644 index 00000000..e8f172d5 --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsFlywayMigrationIT.java @@ -0,0 +1,610 @@ +package cn.iocoder.muse.server.framework.api; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationInfo; +import org.flywaydb.core.api.output.MigrateResult; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * P1R-7a Events 真实 PostgreSQL / Flyway 迁移验收。 + * + *

本测试会 clean 指定数据库的 public schema,因此只允许连接真实库名以 {@code _test} 结尾的隔离库。 + * 数据库密码只能从环境变量读取,避免 Surefire XML 或命令历史记录泄露凭据。

+ */ +class P1rEventsFlywayMigrationIT { + + /** P1R-7a Events V16 是本任务唯一允许验收到达的目标版本。 */ + private static final String TARGET_VERSION = "16"; + /** V16 Flyway 文件名会转换出的描述,作为“当前版本确实是 Events SSE 扩展迁移”的硬证据。 */ + private static final String TARGET_DESCRIPTION = "extend events sse schema"; + /** clean 后从 V1 到 V16 应该产生 16 条成功 SQL migration 记录。 */ + private static final int EXPECTED_SUCCESSFUL_SQL_MIGRATIONS = 16; + + private static final String UNIFIED_EVENT_TABLE = "muse_unified_event"; + private static final String UNIFIED_EVENT_SEQUENCE = "muse_unified_event_sequence_no_seq"; + + private static final List REQUIRED_INDEXES = List.of( + "idx_muse_unified_event_owner_sequence" + ); + + private static final List REQUIRED_CONSTRAINTS = List.of( + "uk_muse_unified_event_sequence", + "uk_muse_unified_event_command", + "uk_muse_unified_event_event", + "uk_muse_unified_event_source", + "chk_muse_unified_event_event_type", + "chk_muse_unified_event_publish_status" + ); + + private static final List REQUIRED_TRIGGERS = List.of( + "trg_muse_unified_event_updated_at" + ); + + private static final Set CREDENTIAL_QUERY_KEYS = Set.of( + "user", + "username", + "password", + "pass", + "pwd", + "sslpassword", + "ssl_password", + "token", + "secret", + "api_key", + "apikey", + "bearer", + "access_token", + "refresh_token" + ); + + @Test + void should_migrateV1ToV16OnRealPostgresqlAndVerifyEventsSchema() throws Exception { + String url = requiredProperty("p1r.flyway.url"); + String user = requiredProperty("p1r.flyway.user"); + String password = requiredPasswordEnvironment(); + String requestedLocations = requiredProperty("p1r.flyway.locations"); + redactFlywaySystemProperties(url, user); + assertEquals("filesystem:sql/muse", requestedLocations, + "P1R-7a Events Flyway IT 要求显式使用 filesystem:sql/muse"); + String effectiveLocations = resolveMuseSqlLocation(requestedLocations); + silenceFlywayInfoLogs(); + + assertNoCredentialQuery(url); + assertTestDatabaseUrl(url); + + Flyway flyway = Flyway.configure() + .dataSource(url, user, password) + .locations(effectiveLocations) + .schemas("public") + .defaultSchema("public") + .target(TARGET_VERSION) + .cleanDisabled(false) + .load(); + + cleanSchema(flyway, url, user); + MigrateResult result = migrateSchema(flyway, url, user); + + MigrationInfo current = flyway.info().current(); + assertNotNull(current, "必须存在当前 Flyway 版本"); + assertEquals(TARGET_VERSION, Objects.requireNonNull(current.getVersion(), "当前版本必须包含版本号").getVersion(), + "真实 Flyway 当前版本必须到 V16"); + assertEquals(TARGET_DESCRIPTION, current.getDescription(), + "真实 Flyway 当前版本必须是 Events V16 扩展迁移"); + assertTrue(result.migrationsExecuted >= EXPECTED_SUCCESSFUL_SQL_MIGRATIONS, + "clean 后必须至少执行 V1-V16 共 16 个迁移,实际: " + result.migrationsExecuted); + int successfulMigrationCount = successfulMigrationCount(url, user, password); + assertEquals(EXPECTED_SUCCESSFUL_SQL_MIGRATIONS, successfulMigrationCount, + "必须在隔离库中执行 V1-V16 共 16 个成功 SQL 迁移"); + + try (Connection connection = DriverManager.getConnection(url, user, password)) { + assertRequiredObjects(connection); + assertUnifiedEventSchema(connection); + } + + System.out.println("flyway_success=true"); + System.out.println("flyway_url=" + maskedUrl(url)); + System.out.println("flyway_locations_requested=" + requestedLocations); + System.out.println("flyway_locations_effective=" + effectiveLocations); + System.out.println("migrations_executed=" + result.migrationsExecuted); + System.out.println("successful_migration_count=" + successfulMigrationCount); + System.out.println("target_schema_version=" + TARGET_VERSION); + System.out.println("flyway_latest=" + current.getVersion() + ":" + current.getDescription()); + System.out.println("schema_version=" + current.getVersion()); + System.out.println("v16_table=" + UNIFIED_EVENT_TABLE); + System.out.println("v16_sequence=" + UNIFIED_EVENT_SEQUENCE); + System.out.println("v16_indexes=" + String.join(",", REQUIRED_INDEXES)); + System.out.println("v16_constraints=" + String.join(",", REQUIRED_CONSTRAINTS)); + System.out.println("v16_triggers=" + String.join(",", REQUIRED_TRIGGERS)); + } + + @Test + void should_rejectNonTestDatabaseWhenQueryContainsSlashTestSuffix() { + String url = "jdbc:postgresql://prod-host/prod?applicationName=/muse_local_p1r7a_test"; + + AssertionError error = assertThrows(AssertionError.class, () -> assertTestDatabaseUrl(url)); + + assertTrue(error.getMessage().contains("jdbc:postgresql:///prod?"), + "拒绝信息必须只展示真实 database name,并脱敏 query: " + error.getMessage()); + } + + @Test + void should_allowTestDatabaseWithQueryAndMaskRealDatabaseName() { + String url = "jdbc:postgresql://prod-host:5432/muse_local_p1r7a_test?applicationName=/muse-local&sslmode=disable"; + + assertTestDatabaseUrl(url); + + assertEquals("jdbc:postgresql://:5432/muse_local_p1r7a_test?", maskedUrl(url), + "合法 _test 库携带 query 时必须保留真实 database name,并整体脱敏 query"); + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name); + return value; + } + + private static String requiredPasswordEnvironment() { + String password = firstNonBlankEnvironment("P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, "缺少必需环境变量: P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD"); + return password; + } + + private static String firstNonBlankEnvironment(String... names) { + // WHY:数据库密码不能通过 JVM system property 传入,否则 Surefire XML 可能记录属性名和值。 + for (String name : names) { + String value = System.getenv(name); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static String resolveMuseSqlLocation(String requestedLocations) { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + String relativeLocation = requestedLocations.substring("filesystem:".length()); + for (Path cursor = current; cursor != null; cursor = cursor.getParent()) { + Path candidate = cursor.resolve(relativeLocation); + if (Files.isDirectory(candidate)) { + return "filesystem:" + candidate; + } + } + throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current); + } + + private static void silenceFlywayInfoLogs() { + try { + Object flywayLogger = LoggerFactory.getLogger("org.flywaydb"); + Class levelClass = Class.forName("ch.qos.logback.classic.Level"); + Object warnLevel = levelClass.getField("WARN").get(null); + flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel); + } catch (ReflectiveOperationException | LinkageError ignored) { + // WHY:日志实现不是 logback 时不影响迁移验收;测试自身仍只输出脱敏 URL。 + } + } + + private static void redactFlywaySystemProperties(String url, String user) { + // WHY:Surefire XML 会记录 JVM system property;读取后立即脱敏,避免报告文件残留真实连接信息。 + System.setProperty("p1r.flyway.url", maskedUrl(url)); + System.setProperty("p1r.flyway.user", maskUser(user)); + } + + private static void cleanSchema(Flyway flyway, String url, String user) { + try { + flyway.clean(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway clean 失败", url, user, exception); + } + } + + private static MigrateResult migrateSchema(Flyway flyway, String url, String user) { + try { + return flyway.migrate(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway migrate 失败", url, user, exception); + } + } + + private static AssertionError sanitizedFlywayFailure(String action, String url, String user, RuntimeException exception) { + // WHY:Flyway 连接异常默认会打印原始 JDBC URL;这里统一替换成 masked URL,避免测试日志暴露环境细节。 + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(url, maskedUrl(url)) + .replace("for user '" + user + "'", "for user ''"); + return new AssertionError(action + ": " + sanitizedMessage); + } + + private static int successfulMigrationCount(String url, String user, String password) throws SQLException { + try (Connection connection = DriverManager.getConnection(url, user, password); + PreparedStatement statement = connection.prepareStatement( + "SELECT COUNT(*) FROM flyway_schema_history WHERE success = TRUE AND type = 'SQL'"); + ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "必须能读取 flyway_schema_history"); + return resultSet.getInt(1); + } + } + + private static void assertRequiredObjects(Connection connection) throws SQLException { + assertTrue(tableExists(connection, UNIFIED_EVENT_TABLE), + "Events 统一事件投影表必须存在: " + UNIFIED_EVENT_TABLE); + assertTrue(sequenceExists(connection, UNIFIED_EVENT_SEQUENCE), + "Events sequence 必须存在: " + UNIFIED_EVENT_SEQUENCE); + + Map indexChecks = indexChecks(connection); + assertFalse(indexChecks.containsValue(false), "Events 关键索引必须全部存在: " + indexChecks); + + Map constraintChecks = constraintChecks(connection); + assertFalse(constraintChecks.containsValue(false), "Events 关键约束必须全部存在: " + constraintChecks); + + Map triggerChecks = triggerChecks(connection); + assertFalse(triggerChecks.containsValue(false), "Events 更新时间触发器必须全部存在: " + triggerChecks); + } + + private static boolean tableExists(Connection connection, String tableName) throws SQLException { + return exists(connection, + "SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?", + tableName); + } + + private static boolean sequenceExists(Connection connection, String sequenceName) throws SQLException { + return exists(connection, + "SELECT 1 FROM information_schema.sequences WHERE sequence_schema = 'public' AND sequence_name = ?", + sequenceName); + } + + private static Map indexChecks(Connection connection) throws SQLException { + Map checks = new LinkedHashMap<>(); + for (String indexName : REQUIRED_INDEXES) { + checks.put(indexName, exists(connection, + "SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = ?", + indexName)); + } + return checks; + } + + private static Map constraintChecks(Connection connection) throws SQLException { + Map checks = new LinkedHashMap<>(); + for (String constraintName : REQUIRED_CONSTRAINTS) { + checks.put(constraintName, exists(connection, + """ + SELECT 1 + FROM pg_constraint c + JOIN pg_namespace n ON n.oid = c.connamespace + WHERE n.nspname = 'public' + AND c.conname = ? + """, + constraintName)); + } + return checks; + } + + private static Map triggerChecks(Connection connection) throws SQLException { + Map checks = new LinkedHashMap<>(); + for (String triggerName : REQUIRED_TRIGGERS) { + checks.put(triggerName, exists(connection, + """ + SELECT 1 + FROM information_schema.triggers + WHERE trigger_schema = 'public' + AND trigger_name = ? + """, + triggerName)); + } + return checks; + } + + private static boolean exists(Connection connection, String sql, String value) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, value); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next(); + } + } + } + + private static void assertUnifiedEventSchema(Connection connection) throws SQLException { + assertColumnsExist(connection, UNIFIED_EVENT_TABLE, + "id", "tenant_id", "command_id", "event_id", "sequence_no", "event_type", + "owner_user_id", "source_owner", "source_type", "source_id", "source_revision", + "resource_type", "resource_id", "payload_summary", "publish_status", + "publish_error_code", "visible_from", "emitted_at", "deleted", + "creator", "create_time", "updater", "update_time"); + assertEquals("jsonb", columnUdtName(connection, UNIFIED_EVENT_TABLE, "payload_summary"), + "payload_summary 必须使用 jsonb 保存可公开事件摘要"); + assertTrue(columnDefault(connection, UNIFIED_EVENT_TABLE, "sequence_no") + .contains("nextval('muse_unified_event_sequence_no_seq"), + "sequence_no 必须由 PostgreSQL sequence 默认分配"); + assertEquals("__none__", columnDefaultLiteral(connection, UNIFIED_EVENT_TABLE, "source_revision"), + "source_revision 默认值必须是 __none__ sentinel"); + assertEquals("'{}'::jsonb", normalizeSql(columnDefault(connection, UNIFIED_EVENT_TABLE, "payload_summary")), + "payload_summary 默认值必须是空 JSONB 对象"); + assertEquals("false", normalizeSql(columnDefault(connection, UNIFIED_EVENT_TABLE, "deleted")), + "deleted 默认值必须是 false"); + assertEquals("", columnDefaultLiteral(connection, UNIFIED_EVENT_TABLE, "creator"), + "creator 默认值必须与 BaseDO 字段约定保持一致"); + assertEquals("", columnDefaultLiteral(connection, UNIFIED_EVENT_TABLE, "updater"), + "updater 默认值必须与 BaseDO 字段约定保持一致"); + + assertTrue(columnIsNotNullable(connection, UNIFIED_EVENT_TABLE, "command_id"), + "command_id 必须 NOT NULL"); + assertTrue(columnIsNotNullable(connection, UNIFIED_EVENT_TABLE, "source_revision"), + "source_revision 必须 NOT NULL"); + assertTrue(columnIsNotNullable(connection, UNIFIED_EVENT_TABLE, "creator"), + "creator 必须 NOT NULL,避免 BaseDO 映射列缺失或出现空审计值"); + assertTrue(columnIsNotNullable(connection, UNIFIED_EVENT_TABLE, "updater"), + "updater 必须 NOT NULL,避免 BaseDO 映射列缺失或出现空审计值"); + assertEquals(List.of("tenant_id", "command_id"), + constraintColumns(connection, "uk_muse_unified_event_command"), + "命令幂等唯一约束必须覆盖 tenant_id/command_id"); + assertEquals(List.of("tenant_id", "sequence_no"), + constraintColumns(connection, "uk_muse_unified_event_sequence"), + "sequence 唯一约束必须覆盖 tenant_id/sequence_no"); + assertEquals(List.of("tenant_id", "event_id"), + constraintColumns(connection, "uk_muse_unified_event_event"), + "event 唯一约束必须覆盖 tenant_id/event_id"); + assertEquals(List.of("tenant_id", "source_owner", "source_type", "source_id", "source_revision", "event_type"), + constraintColumns(connection, "uk_muse_unified_event_source"), + "source tuple 唯一约束必须覆盖 source_revision sentinel"); + assertEquals(List.of("tenant_id", "owner_user_id", "sequence_no"), + indexColumns(connection, "idx_muse_unified_event_owner_sequence"), + "SSE replay 索引必须覆盖 tenant_id/owner_user_id/sequence_no"); + + String eventTypeConstraint = normalizedConstraintDefinition(connection, "chk_muse_unified_event_event_type"); + for (String value : Set.of("chunk", "quality_check", "done", "error", "notification")) { + assertTrue(eventTypeConstraint.contains(value), + "event_type check 必须允许 " + value + ": " + eventTypeConstraint); + } + String publishStatusConstraint = normalizedConstraintDefinition(connection, "chk_muse_unified_event_publish_status"); + for (String value : Set.of("accepted", "rejected", "blocked")) { + assertTrue(publishStatusConstraint.contains(value), + "publish_status check 必须允许 " + value + ": " + publishStatusConstraint); + } + } + + private static void assertColumnsExist(Connection connection, String tableName, String... columnNames) throws SQLException { + for (String columnName : columnNames) { + assertTrue(columnExists(connection, tableName, columnName), + tableName + " 必须包含字段: " + columnName); + } + } + + private static boolean columnExists(Connection connection, String tableName, String columnName) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = ? + """)) { + statement.setString(1, tableName); + statement.setString(2, columnName); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next(); + } + } + } + + private static boolean columnIsNotNullable(Connection connection, String tableName, String columnName) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + SELECT is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = ? + """)) { + statement.setString(1, tableName); + statement.setString(2, columnName); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), tableName + " 缺少字段: " + columnName); + return "NO".equals(resultSet.getString(1)); + } + } + } + + private static String columnUdtName(Connection connection, String tableName, String columnName) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + SELECT udt_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = ? + """)) { + statement.setString(1, tableName); + statement.setString(2, columnName); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), tableName + " 缺少字段: " + columnName); + return resultSet.getString(1); + } + } + } + + private static String columnDefault(Connection connection, String tableName, String columnName) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + SELECT column_default + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = ? + """)) { + statement.setString(1, tableName); + statement.setString(2, columnName); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), tableName + " 缺少字段: " + columnName); + return Objects.toString(resultSet.getString(1), ""); + } + } + } + + private static String columnDefaultLiteral(Connection connection, String tableName, String columnName) throws SQLException { + String value = normalizeSql(columnDefault(connection, tableName, columnName)); + int quotedValueStart = value.indexOf('\''); + int quotedValueEnd = value.indexOf('\'', quotedValueStart + 1); + assertTrue(quotedValueStart >= 0 && quotedValueEnd > quotedValueStart, + "字段默认值必须是字符串字面量: " + tableName + "." + columnName + "=" + value); + return value.substring(quotedValueStart + 1, quotedValueEnd); + } + + private static String normalizedConstraintDefinition(Connection connection, String constraintName) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + SELECT pg_get_constraintdef(c.oid) + FROM pg_constraint c + JOIN pg_namespace n ON n.oid = c.connamespace + WHERE n.nspname = 'public' + AND c.conname = ? + """)) { + statement.setString(1, constraintName); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "缺少约束定义: " + constraintName); + return normalizeSql(resultSet.getString(1)); + } + } + } + + private static List indexColumns(Connection connection, String indexName) throws SQLException { + String sql = """ + SELECT a.attname + FROM pg_class i + JOIN pg_namespace ni ON ni.oid = i.relnamespace + JOIN pg_index ix ON ix.indexrelid = i.oid + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_namespace nt ON nt.oid = t.relnamespace + JOIN pg_attribute a ON a.attrelid = ix.indrelid AND a.attnum = ANY(ix.indkey) + WHERE ni.nspname = 'public' + AND nt.nspname = 'public' + AND i.relname = ? + ORDER BY array_position(ix.indkey, a.attnum) + """; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, indexName); + try (ResultSet resultSet = statement.executeQuery()) { + List columns = new ArrayList<>(); + while (resultSet.next()) { + columns.add(resultSet.getString(1)); + } + return columns; + } + } + } + + private static List constraintColumns(Connection connection, String constraintName) throws SQLException { + String sql = """ + SELECT a.attname + FROM pg_constraint c + JOIN pg_namespace nc ON nc.oid = c.connamespace + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace nt ON nt.oid = t.relnamespace + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey) + WHERE nc.nspname = 'public' + AND nt.nspname = 'public' + AND c.conname = ? + ORDER BY array_position(c.conkey, a.attnum) + """; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, constraintName); + try (ResultSet resultSet = statement.executeQuery()) { + List columns = new ArrayList<>(); + while (resultSet.next()) { + columns.add(resultSet.getString(1)); + } + return columns; + } + } + } + + private static void assertNoCredentialQuery(String url) { + int queryStart = url.indexOf('?'); + if (queryStart < 0) { + return; + } + String query = url.substring(queryStart + 1); + for (String parameter : query.split("&")) { + String key = parameter; + int equalsStart = key.indexOf('='); + if (equalsStart >= 0) { + key = key.substring(0, equalsStart); + } + assertFalse(isCredentialQueryKey(key), + "p1r.flyway.url 不能携带凭据 query 参数;请通过用户名属性和密码环境变量传入"); + } + } + + private static boolean isCredentialQueryKey(String rawKey) { + // WHY:URL 可能会被 Surefire 或日志记录,凭据只能走单独参数或环境变量,不能藏在 JDBC query 中。 + String key = rawKey.trim().toLowerCase(Locale.ROOT).replace('-', '_'); + return CREDENTIAL_QUERY_KEYS.contains(key) + || key.endsWith("_token") + || key.endsWith("_secret") + || key.endsWith("_password"); + } + + private static void assertTestDatabaseUrl(String url) { + // WHY:Flyway clean 会删除 public schema 内全部对象,只能允许真实 database name 以 _test 结尾。 + // query 由调用方控制,不能参与库名判断,否则 applicationName=/xxx_test 这类参数会绕过安全闸。 + String databaseName = jdbcDatabaseName(url); + assertTrue(databaseName.endsWith("_test"), + "p1r.flyway.url 必须指向 _test 后缀隔离库,避免清理非测试库: " + maskedUrl(url)); + } + + private static String jdbcDatabaseName(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1, + "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url)); + return urlWithoutQuery.substring(databaseStart + 1); + } + + private static String jdbcUrlWithoutQuery(String url) { + int queryStart = url.indexOf('?'); + return queryStart < 0 ? url : url.substring(0, queryStart); + } + + private static String maskedUrl(String url) { + // WHY:脱敏输出既要保留真实 database name 便于排查,又不能让 query 中的 slash 干扰库名解析。 + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + if (databaseStart < 0) { + return maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url); + } + String prefix = urlWithoutQuery.substring(0, databaseStart + 1); + String database = urlWithoutQuery.substring(databaseStart + 1); + return maskJdbcHost(prefix) + database + maskedQuerySuffix(url); + } + + private static String maskedQuerySuffix(String url) { + return url.indexOf('?') < 0 ? "" : "?"; + } + + private static String maskJdbcHost(String urlPart) { + return urlPart.replaceAll("//([^:/?#]+)", "//"); + } + + private static String maskUser(String user) { + return user == null || user.isBlank() ? "" : ""; + } + + private static String normalizeSql(String sql) { + return sql.toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); + } + +} diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsMigrationSqlTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsMigrationSqlTest.java new file mode 100644 index 00000000..ed32fe7c --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsMigrationSqlTest.java @@ -0,0 +1,239 @@ +package cn.iocoder.muse.server.framework.api; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * P1R-7a Events V16 迁移 SQL 静态门禁。 + */ +class P1rEventsMigrationSqlTest { + + private static final String UNIFIED_EVENT_TABLE = "muse_unified_event"; + + private static final Pattern PLAINTEXT_CREDENTIAL_COLUMN_PATTERN = Pattern.compile( + "(?i)\\b\\w*(?:token|authorization|secret|bearer|api_key|apikey|password)\\w*\\s+" + + "(?:varchar|text|jsonb|char|bytea)"); + + private static final Pattern UNSAFE_SEQUENCE_ALLOCATION_PATTERN = Pattern.compile( + "(?i)max\\s*\\(\\s*sequence_no\\s*\\)\\s*\\+\\s*1"); + + @Test + void should_includeUnifiedEventTableSequenceAndRequiredColumns() throws IOException { + String normalizedSql = normalize(readV16Sql()); + String tableSql = extractCreateTable(normalizedSql, UNIFIED_EVENT_TABLE); + + assertTrue(normalizedSql.contains("create sequence muse_unified_event_sequence_no_seq"), + "V16 必须创建 Events 全局 sequence"); + assertTrue(tableSql.contains("id bigserial primary key") + || tableSql.contains("id bigint generated always as identity primary key") + || tableSql.contains("id bigint generated by default as identity primary key"), + "muse_unified_event.id 必须是 BIGSERIAL 或 PostgreSQL identity 主键"); + for (String column : Set.of( + "tenant_id bigint not null", + "command_id varchar(128) not null", + "event_id varchar(128) not null", + "sequence_no bigint not null", + "event_type varchar(32) not null", + "owner_user_id bigint not null", + "source_owner varchar(64) not null", + "source_type varchar(64) not null", + "source_id varchar(128) not null", + "source_revision varchar(128) not null", + "resource_type varchar(64)", + "resource_id varchar(128)", + "payload_summary jsonb not null", + "publish_status varchar(32) not null", + "publish_error_code varchar(64)", + "visible_from timestamp not null", + "emitted_at timestamp not null", + "deleted boolean not null default false", + "creator varchar(64) not null default ''", + "create_time timestamp not null default current_timestamp", + "updater varchar(64) not null default ''", + "update_time timestamp not null default current_timestamp")) { + assertTrue(tableSql.contains(column), + "muse_unified_event 必须包含字段定义: " + column); + } + } + + @Test + void should_includeDefaultsUniqueConstraintsIndexesChecksAndTrigger() throws IOException { + String normalizedSql = normalize(readV16Sql()); + String tableSql = extractCreateTable(normalizedSql, UNIFIED_EVENT_TABLE); + + assertTrue(tableSql.contains("sequence_no bigint not null default nextval('muse_unified_event_sequence_no_seq')"), + "sequence_no 必须由 PostgreSQL sequence 默认分配"); + assertTrue(normalizedSql.contains("alter sequence muse_unified_event_sequence_no_seq owned by muse_unified_event.sequence_no"), + "sequence 必须归属 muse_unified_event.sequence_no,便于 schema 生命周期跟随表"); + assertTrue(tableSql.contains("payload_summary jsonb not null default '{}'::jsonb"), + "payload_summary 必须是 JSONB 且默认空对象"); + assertTrue(tableSql.contains("source_revision varchar(128) not null default '__none__'"), + "source_revision 必须 NOT NULL 且无版本时使用 __none__ sentinel"); + + assertTrue(tableSql.contains("constraint uk_muse_unified_event_sequence unique (tenant_id, sequence_no)"), + "V16 必须用 (tenant_id, sequence_no) 唯一键保证 cursor 单调唯一"); + assertTrue(tableSql.contains("constraint uk_muse_unified_event_command unique (tenant_id, command_id)"), + "V16 必须用 (tenant_id, command_id) 唯一键保证发布命令幂等"); + assertTrue(tableSql.contains("constraint uk_muse_unified_event_event unique (tenant_id, event_id)"), + "V16 必须用 (tenant_id, event_id) 唯一键保证事件 ID 幂等"); + assertTrue(tableSql.contains("constraint uk_muse_unified_event_source unique (tenant_id, source_owner, source_type, source_id, source_revision, event_type)"), + "V16 source tuple 唯一键必须覆盖 source_revision sentinel 字段"); + assertTrue(normalizedSql.contains("create index idx_muse_unified_event_owner_sequence") + && normalizedSql.contains("on muse_unified_event(tenant_id, owner_user_id, sequence_no)"), + "V16 必须具备 tenant/owner/sequence replay 索引"); + + String eventTypeConstraint = extractConstraint(tableSql, "chk_muse_unified_event_event_type"); + for (String value : Set.of("chunk", "quality_check", "done", "error", "notification")) { + assertTrue(eventTypeConstraint.contains("'" + value + "'"), + "event_type check 必须允许: " + value); + } + String publishStatusConstraint = extractConstraint(tableSql, "chk_muse_unified_event_publish_status"); + for (String value : Set.of("accepted", "rejected", "blocked")) { + assertTrue(publishStatusConstraint.contains("'" + value + "'"), + "publish_status check 必须允许: " + value); + } + assertTrue(normalizedSql.contains("create trigger trg_muse_unified_event_updated_at") + && normalizedSql.contains("before update on muse_unified_event") + && normalizedSql.contains("execute function update_updated_at_column()"), + "V16 必须复用 update_updated_at_column() 更新时间 trigger"); + } + + @Test + void should_rejectPlaintextCredentialColumnsAndUnsafeSequenceAllocation() throws IOException { + String sql = readV16Sql(); + String tableSql = extractCreateTable(normalize(sql), UNIFIED_EVENT_TABLE); + + assertFalse(PLAINTEXT_CREDENTIAL_COLUMN_PATTERN.matcher(tableSql).find(), + "Events 统一投影不能新增 token / authorization / secret 等明文字段"); + assertFalse(UNSAFE_SEQUENCE_ALLOCATION_PATTERN.matcher(sql).find(), + "sequence_no 必须由 PostgreSQL sequence 分配,禁止 max(sequence_no)+1"); + } + + @Test + void should_requireCommandAndSourceRevisionIdempotencyContracts() throws IOException { + String normalizedSql = normalize(readV16Sql()); + String tableSql = extractCreateTable(normalizedSql, UNIFIED_EVENT_TABLE); + + assertTrue(tableSql.contains("command_id varchar(128) not null"), + "command_id 必须 NOT NULL,不能让幂等命令绕过唯一约束"); + assertTrue(tableSql.contains("constraint uk_muse_unified_event_command unique (tenant_id, command_id)"), + "command_id 必须具备 (tenant_id, command_id) 唯一约束"); + assertTrue(tableSql.contains("source_revision varchar(128) not null default '__none__'"), + "source_revision 必须 NOT NULL DEFAULT '__none__',避免 PostgreSQL NULL unique 不去重"); + assertTrue(tableSql.contains("constraint uk_muse_unified_event_source unique (tenant_id, source_owner, source_type, source_id, source_revision, event_type)"), + "source tuple 唯一约束必须覆盖 source_revision"); + } + + @Test + void should_not_duplicateNewObjectNames() throws IOException { + String normalizedSql = normalize(readV16Sql()); + + assertNoDuplicateNames(extractNames(normalizedSql, Pattern.compile("\\bcreate sequence (muse_[a-z0-9_]+)\\b")), + "V16 新增 sequence 名不能重复"); + assertNoDuplicateNames(extractNames(normalizedSql, Pattern.compile("\\bcreate table (muse_[a-z0-9_]+)\\b")), + "V16 新增表名不能重复"); + assertNoDuplicateNames(extractNames(normalizedSql, Pattern.compile("\\bcreate (?:unique )?index (\\w+)\\b")), + "V16 新增索引名不能重复"); + assertNoDuplicateNames(extractNames(normalizedSql, Pattern.compile("\\bcreate trigger (\\w+)\\b")), + "V16 新增 trigger 名不能重复"); + assertNoDuplicateNames(extractNames(normalizedSql, Pattern.compile("\\bconstraint (\\w+)\\b")), + "V16 新增 constraint 名不能重复"); + } + + @Test + void should_requireEventsFlywayMigrationItToTargetV16AndGuardCredentials() throws IOException { + Path flywayTestPath = findRepositoryRoot().resolve( + "muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsFlywayMigrationIT.java"); + assertTrue(Files.exists(flywayTestPath), + "新增 V16 后必须补充真实 PostgreSQL / Flyway IT 迁移验收"); + String source = Files.readString(flywayTestPath); + + assertTrue(source.contains("TARGET_VERSION = \"16\""), + "Events Flyway IT 必须验收到 V16"); + assertTrue(source.contains("TARGET_DESCRIPTION = \"extend events sse schema\""), + "Events Flyway IT 必须验收 V16 文件描述"); + assertTrue(source.contains("EXPECTED_SUCCESSFUL_SQL_MIGRATIONS = 16"), + "Events Flyway IT 必须断言 V1-V16 共 16 个成功 SQL 迁移"); + assertTrue(source.contains("requiredPasswordEnvironment()"), + "Events Flyway IT 必须只从环境变量读取数据库密码"); + assertTrue(source.contains("\"P1R_FLYWAY_PASSWORD\"") + && source.contains("\"MUSE_POSTGRES_PASSWORD\""), + "Events Flyway IT 必须支持 P1R_FLYWAY_PASSWORD / MUSE_POSTGRES_PASSWORD"); + assertFalse(source.contains("System.getProperty(\"p1r.flyway.password\")"), + "Events Flyway IT 不能从 system property 读取数据库密码"); + assertTrue(source.contains("assertNoCredentialQuery(url);"), + "Events Flyway IT 必须拒绝 JDBC URL query 凭据"); + assertTrue(source.contains("assertTestDatabaseUrl(url);"), + "Events Flyway IT 必须限制只清理 _test 后缀数据库"); + assertTrue(source.contains("flyway_latest=\" + current.getVersion() + \":\" + current.getDescription()"), + "Events Flyway IT 必须输出 flyway_latest 证据"); + } + + private static String readV16Sql() throws IOException { + Path migrationPath = findRepositoryRoot().resolve("muse-cloud/sql/muse/V16__extend_events_sse_schema.sql"); + assertTrue(Files.exists(migrationPath), "V16 Events 迁移 SQL 必须存在"); + return Files.readString(migrationPath); + } + + /** + * 从当前 Maven 执行目录逐级向上查找仓库根目录,避免 surefire 在不同模块目录执行时路径失效。 + */ + private static Path findRepositoryRoot() { + Path current = Path.of("").toAbsolutePath(); + for (Path candidate = current; candidate != null; candidate = candidate.getParent()) { + if (Files.exists(candidate.resolve("muse-cloud/sql/muse/V1__init_content_schema.sql"))) { + return candidate; + } + } + return current; + } + + private static String normalize(String sql) { + return sql.toLowerCase(Locale.ROOT) + .replaceAll("--.*", " ") + .replaceAll("\\s+", " "); + } + + private static String extractCreateTable(String normalizedSql, String tableName) { + String startToken = "create table " + tableName + " "; + int start = normalizedSql.indexOf(startToken); + assertTrue(start >= 0, "V16 必须创建表: " + tableName); + int end = normalizedSql.indexOf(");", start + startToken.length()); + assertTrue(end >= 0, "V16 表定义必须以 ); 结束: " + tableName); + return normalizedSql.substring(start, end + 2); + } + + private static String extractConstraint(String tableSql, String constraintName) { + String startToken = "constraint " + constraintName; + int start = tableSql.indexOf(startToken); + assertTrue(start >= 0, "V16 必须创建约束: " + constraintName); + int next = tableSql.indexOf("constraint ", start + startToken.length()); + return next >= 0 ? tableSql.substring(start, next) : tableSql.substring(start); + } + + private static List extractNames(String normalizedSql, Pattern pattern) { + Matcher matcher = pattern.matcher(normalizedSql); + return matcher.results() + .map(result -> result.group(1)) + .toList(); + } + + private static void assertNoDuplicateNames(List names, String message) { + Set uniqueNames = new HashSet<>(names); + assertEquals(uniqueNames.size(), names.size(), message + ": " + names); + } + +} diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRouteOwnershipTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRouteOwnershipTest.java new file mode 100644 index 00000000..ae3e106e --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRouteOwnershipTest.java @@ -0,0 +1,207 @@ +package cn.iocoder.muse.server.framework.api; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * P1R-7a Task 2 Events 路由归属静态门禁。 + * + *

该测试只证明统一事件流路由已迁移到 Events owner 骨架, + * 不验证真实 SSE replay、heartbeat、error、cursor 语义。

+ */ +class P1rEventsRouteOwnershipTest { + + private static final String MUSE_CLOUD_ROOT = "muse-cloud"; + + private static final String CONTENT_EVENTS_CONTROLLER = "muse-cloud/muse-module-content/" + + "muse-module-content-server/src/main/java/cn/iocoder/muse/module/content/controller/app/AppMuseEventsController.java"; + + private static final String EVENTS_SERVER_POM = "muse-cloud/muse-module-events/muse-module-events-server/pom.xml"; + + private static final String MUSE_SERVER_POM = "muse-cloud/muse-server/pom.xml"; + + private static final String STREAM_EVENTS_ROUTE = "GET /app-api/muse/events"; + + private static final Pattern METHOD_MAPPING_PATTERN = Pattern.compile( + "@(GetMapping|PostMapping|PatchMapping|PutMapping|DeleteMapping|RequestMapping)(?:\\s*\\((.*?)\\))?", + Pattern.DOTALL); + + private static final Pattern REQUEST_METHOD_PATTERN = Pattern.compile("RequestMethod\\.([A-Z]+)"); + + @Test + void should_keep_stream_events_route_owned_only_by_events_module_controller() throws IOException { + Map owners = collectStreamEventsRouteOwners(); + + assertEquals(1, owners.size(), "P1R-7a streamEvents 路由只能有一个 Controller owner"); + Path owner = owners.keySet().iterator().next(); + assertTrue(owner.toString().contains("/cn/iocoder/muse/module/events/controller/app/"), + "streamEvents 必须由 Events module app Controller 持有: " + owner); + assertTrue(Files.readString(owner).contains("streamEvents("), + "Events owner Controller 必须声明 streamEvents 方法: " + owner); + } + + @Test + void should_retire_content_events_controller_route_owner() throws IOException { + Path sourceFile = findRepositoryRoot().resolve(CONTENT_EVENTS_CONTROLLER); + if (!Files.exists(sourceFile)) { + return; + } + + String source = Files.readString(sourceFile); + Map routes = new TreeMap<>(); + collectRoutesFromFile(sourceFile, source, routes); + + assertFalse(routes.containsKey(STREAM_EVENTS_ROUTE), + "Content 旧 AppMuseEventsController 不能继续声明 /muse/events: " + sourceFile); + assertFalse(source.contains("streamEvents("), + "Content 旧 AppMuseEventsController 不能继续持有 streamEvents 方法: " + sourceFile); + } + + @Test + void should_not_let_events_server_depend_on_source_owner_servers() throws IOException { + Path pom = findRepositoryRoot().resolve(EVENTS_SERVER_POM); + assertTrue(Files.exists(pom), "Events server pom.xml 必须存在"); + + String source = Files.readString(pom); + for (String forbiddenArtifactId : Set.of( + "muse-module-ai-server", + "muse-module-knowledge-server", + "muse-module-market-server", + "muse-module-member-server", + "muse-module-content-server")) { + assertFalse(source.contains("" + forbiddenArtifactId + ""), + "Events server 不能反向依赖 source owner server: " + forbiddenArtifactId); + } + } + + @Test + void should_assemble_events_server_in_muse_server() throws IOException { + Path pom = findRepositoryRoot().resolve(MUSE_SERVER_POM); + String source = Files.readString(pom); + + assertTrue(source.contains("muse-module-events-server"), + "muse-server 必须装配 muse-module-events-server"); + } + + private static Map collectStreamEventsRouteOwners() throws IOException { + Map owners = new LinkedHashMap<>(); + Path root = findRepositoryRoot().resolve(MUSE_CLOUD_ROOT); + + try (Stream stream = Files.walk(root)) { + for (Path sourceFile : stream + .filter(Files::isRegularFile) + .filter(path -> path.toString().contains("/src/main/java/")) + .filter(path -> path.toString().contains("/controller/")) + .filter(path -> path.getFileName().toString().endsWith("Controller.java")) + .toList()) { + Map routes = new TreeMap<>(); + collectRoutesFromFile(sourceFile, Files.readString(sourceFile), routes); + if (routes.containsKey(STREAM_EVENTS_ROUTE)) { + owners.put(sourceFile, true); + } + } + } + return owners; + } + + private static void collectRoutesFromFile(Path sourceFile, String source, Map routes) { + String sidePrefix = sourceFile.toString().contains("/controller/admin/") ? "/admin-api" : "/app-api"; + String classBasePath = extractClassBasePath(source); + + Matcher matcher = METHOD_MAPPING_PATTERN.matcher(source); + while (matcher.find()) { + String annotation = matcher.group(1); + String mappingBody = matcher.group(2); + for (String method : extractHttpMethods(annotation, mappingBody)) { + for (String methodPath : extractMappingPaths(mappingBody)) { + String fullPath = normalizePath(sidePrefix, classBasePath, methodPath); + String route = method + " " + fullPath; + if (STREAM_EVENTS_ROUTE.equals(route)) { + routes.put(route, sourceFile); + } + } + } + } + } + + private static String extractClassBasePath(String source) { + int classIndex = source.indexOf("public class "); + String classAnnotations = classIndex >= 0 ? source.substring(0, classIndex) : source; + Matcher matcher = Pattern.compile("@RequestMapping\\s*\\((.*?)\\)", Pattern.DOTALL).matcher(classAnnotations); + if (!matcher.find()) { + return ""; + } + return extractMappingPaths(matcher.group(1)).stream().findFirst().orElse(""); + } + + private static Set extractHttpMethods(String annotation, String mappingBody) { + return switch (annotation) { + case "GetMapping" -> Set.of("GET"); + case "PostMapping" -> Set.of("POST"); + case "PatchMapping" -> Set.of("PATCH"); + case "PutMapping" -> Set.of("PUT"); + case "DeleteMapping" -> Set.of("DELETE"); + default -> { + Matcher matcher = REQUEST_METHOD_PATTERN.matcher(mappingBody == null ? "" : mappingBody); + Map methods = new LinkedHashMap<>(); + while (matcher.find()) { + methods.put(matcher.group(1), true); + } + yield methods.isEmpty() ? Set.of() : methods.keySet(); + } + }; + } + + private static Set extractMappingPaths(String mappingBody) { + if (mappingBody == null || mappingBody.isBlank()) { + return Set.of(""); + } + Matcher matcher = Pattern.compile("\"([^\"]+)\"").matcher(mappingBody); + Map paths = new LinkedHashMap<>(); + while (matcher.find()) { + String value = matcher.group(1); + if (value.startsWith("/")) { + paths.put(value, true); + } + } + return paths.isEmpty() ? Set.of("") : paths.keySet(); + } + + private static String normalizePath(String sidePrefix, String classBasePath, String methodPath) { + return Stream.of(sidePrefix, classBasePath, methodPath) + .filter(Objects::nonNull) + .filter(part -> !part.isBlank()) + .map(part -> part.startsWith("/") ? part.substring(1) : part) + .reduce("", (left, right) -> left + "/" + right) + .replaceAll("/{2,}", "/"); + } + + /** + * 从当前 Maven 执行目录逐级向上查找仓库根目录,避免 surefire 在不同模块目录执行时路径失效。 + */ + private static Path findRepositoryRoot() { + Path current = Path.of("").toAbsolutePath(); + for (Path candidate = current; candidate != null; candidate = candidate.getParent()) { + if (Files.exists(candidate.resolve("muse-cloud/muse-server/pom.xml"))) { + return candidate; + } + } + return current; + } + +} diff --git a/muse-cloud/pom.xml b/muse-cloud/pom.xml index dfac8a47..9ed6eb83 100644 --- a/muse-cloud/pom.xml +++ b/muse-cloud/pom.xml @@ -18,6 +18,7 @@ muse-module-infra muse-module-content muse-module-meta + muse-module-events muse-module-knowledge muse-module-market muse-module-member diff --git a/muse-cloud/sql/muse/V16__extend_events_sse_schema.sql b/muse-cloud/sql/muse/V16__extend_events_sse_schema.sql new file mode 100644 index 00000000..68649b88 --- /dev/null +++ b/muse-cloud/sql/muse/V16__extend_events_sse_schema.sql @@ -0,0 +1,52 @@ +-- P1R-7a Events SSE 统一事件投影 Schema。 +-- 迁移边界:Events owner 只保存可公开事件摘要与发布状态,不保存上游调用凭据或鉴权材料。 + +CREATE SEQUENCE muse_unified_event_sequence_no_seq + AS BIGINT + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +CREATE TABLE muse_unified_event ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + command_id VARCHAR(128) NOT NULL, + event_id VARCHAR(128) NOT NULL, + sequence_no BIGINT NOT NULL DEFAULT nextval('muse_unified_event_sequence_no_seq'), + event_type VARCHAR(32) NOT NULL, + owner_user_id BIGINT NOT NULL, + source_owner VARCHAR(64) NOT NULL, + source_type VARCHAR(64) NOT NULL, + source_id VARCHAR(128) NOT NULL, + source_revision VARCHAR(128) NOT NULL DEFAULT '__none__', + resource_type VARCHAR(64), + resource_id VARCHAR(128), + payload_summary JSONB NOT NULL DEFAULT '{}'::jsonb, + publish_status VARCHAR(32) NOT NULL, + publish_error_code VARCHAR(64), + visible_from TIMESTAMP NOT NULL, + emitted_at TIMESTAMP NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_muse_unified_event_sequence UNIQUE (tenant_id, sequence_no), + CONSTRAINT uk_muse_unified_event_command UNIQUE (tenant_id, command_id), + CONSTRAINT uk_muse_unified_event_event UNIQUE (tenant_id, event_id), + CONSTRAINT uk_muse_unified_event_source UNIQUE (tenant_id, source_owner, source_type, source_id, source_revision, event_type), + CONSTRAINT chk_muse_unified_event_event_type CHECK (event_type IN ('chunk', 'quality_check', 'done', 'error', 'notification')), + CONSTRAINT chk_muse_unified_event_publish_status CHECK (publish_status IN ('accepted', 'rejected', 'blocked')) +); + +ALTER SEQUENCE muse_unified_event_sequence_no_seq + OWNED BY muse_unified_event.sequence_no; + +CREATE INDEX idx_muse_unified_event_owner_sequence + ON muse_unified_event(tenant_id, owner_user_id, sequence_no); + +CREATE TRIGGER trg_muse_unified_event_updated_at + BEFORE UPDATE ON muse_unified_event + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();