feat(p1r): 落地 Events SSE 真实 API
This commit is contained in:
parent
5993b6f51c
commit
165579521a
@ -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 统一事件流入口。
|
|
||||||
*
|
|
||||||
* <p>P1 阶段先提供可建立连接的 SSE 端点,后续由各业务模块接入真实事件发布器。</p>
|
|
||||||
*/
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
36
muse-cloud/muse-module-events/muse-module-events-api/pom.xml
Normal file
36
muse-cloud/muse-module-events/muse-module-events-api/pom.xml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<parent>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse-module-events</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</parent>
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<artifactId>muse-module-events-api</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
<name>${project.artifactId}</name>
|
||||||
|
<description>events 模块 API 和发布契约。</description>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse-common</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.cloud</groupId>
|
||||||
|
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
@ -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<EventsPublishRespDTO> publish(@Valid @RequestBody EventsPublishReqDTO reqDTO);
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<String, Object> payloadSummary;
|
||||||
|
|
||||||
|
@NotNull(message = "emittedAt 不能为空")
|
||||||
|
private LocalDateTime emittedAt;
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package cn.iocoder.muse.module.events.enums;
|
||||||
|
|
||||||
|
import cn.iocoder.muse.framework.common.exception.ErrorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events 错误码。
|
||||||
|
*
|
||||||
|
* <p>Events 使用 1-045-000-000 段,覆盖统一事件发布、幂等投影和 SSE 可见性边界。</p>
|
||||||
|
*/
|
||||||
|
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, "事件发布必要字段不能为空:{}");
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<parent>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse-module-events</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</parent>
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<artifactId>muse-module-events-server</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
<name>${project.artifactId}</name>
|
||||||
|
<description>events 模块服务实现。</description>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse-module-events-api</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse-spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse-spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse-spring-boot-starter-biz-tenant</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse-spring-boot-starter-mybatis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse-spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
@ -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<EventsPublishRespDTO> publish(EventsPublishReqDTO reqDTO) {
|
||||||
|
return success(eventsPublishService.publish(reqDTO));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<UnifiedEventDO> listVisibleEvents(Long tenantId, Long ownerUserId, Long afterSequenceNo, LocalDateTime now);
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<String> 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<String> 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<UnifiedEventDO> 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<String, Object> payload) {
|
||||||
|
Map<String, Object> 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<String, Object> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<String> DECLARED_EVENT_TYPES = Set.of(EVENT_CHUNK, EVENT_QUALITY_CHECK, EVENT_DONE,
|
||||||
|
EVENT_ERROR, EVENT_NOTIFICATION);
|
||||||
|
private static final Set<String> 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<Future<?>> 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<Future<?>> 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<UnifiedEventDO> rows = unifiedEventMapper.selectVisibleEventsForOwner(tenantId, ownerUserId,
|
||||||
|
afterSequenceNo == null ? 0L : afterSequenceNo, LocalDateTime.now());
|
||||||
|
return toStreamBatch(afterSequenceNo, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StreamBatch toStreamBatch(Long fallbackSequenceNo, List<UnifiedEventDO> rows) {
|
||||||
|
Long maxSequenceNo = fallbackSequenceNo == null ? 0L : fallbackSequenceNo;
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return StreamBatch.empty(maxSequenceNo);
|
||||||
|
}
|
||||||
|
List<StreamEvent> 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<String, Object> payload = payloadSanitizer.parseSanitizedJson(row.getPayloadSummary());
|
||||||
|
Map<String, Object> data = toOpenApiData(row.getEventType(), payload);
|
||||||
|
return data == null ? null : new StreamEvent(row.getSequenceNo(), row.getEventType(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> toOpenApiData(String eventType, Map<String, Object> 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<String, Object> chunkData(Map<String, Object> payload) {
|
||||||
|
if (!(payload.get("content") instanceof String content) || !isPositiveLongNumber(payload.get("sequenceNo"))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("content", content);
|
||||||
|
data.put("sequenceNo", toLong(payload.get("sequenceNo")));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> qualityCheckData(Map<String, Object> 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<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("dimension", dimension);
|
||||||
|
data.put("score", scoreValue);
|
||||||
|
data.put("passed", passed);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> doneData(Map<String, Object> payload) {
|
||||||
|
if (!isLongNumber(payload.get("taskId")) || !isLongNumber(payload.get("suggestionId"))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> 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<String, Object> errorData(Map<String, Object> payload) {
|
||||||
|
if (!(payload.get("code") instanceof String code) || !(payload.get("message") instanceof String message)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> 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<String, Object> notificationData(Map<String, Object> payload) {
|
||||||
|
if (!(payload.get("type") instanceof String type) || !NOTIFICATION_TYPES.contains(type)
|
||||||
|
|| !(payload.get("message") instanceof String message)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("type", type);
|
||||||
|
data.put("message", message);
|
||||||
|
if (payload.get("resourceRef") instanceof Map<?, ?> resourceRef) {
|
||||||
|
Map<String, Object> 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<String, Object> resourceRefData(Map<?, ?> resourceRef) {
|
||||||
|
Map<String, Object> 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<Future<?>> 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<String, Object> 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<Future<?>> 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<String, Object> data) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record StreamBatch(List<StreamEvent> events, Long maxSequenceNo) {
|
||||||
|
|
||||||
|
private static StreamBatch empty(Long maxSequenceNo) {
|
||||||
|
return new StreamBatch(List.of(), maxSequenceNo == null ? 0L : maxSequenceNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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 连接。
|
||||||
|
*
|
||||||
|
* <p>登录用户必须在进入 SSE 生命周期前读取,避免后台线程再访问请求安全上下文。</p>
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<UnifiedEventDO> {
|
||||||
|
|
||||||
|
default UnifiedEventDO selectByTenantIdAndCommandId(Long tenantId, String commandId) {
|
||||||
|
return selectOne(new LambdaQueryWrapperX<UnifiedEventDO>()
|
||||||
|
.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<UnifiedEventDO>()
|
||||||
|
.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<UnifiedEventDO> selectVisibleEventsForOwner(Long tenantId, Long ownerUserId, Long afterSequenceNo,
|
||||||
|
LocalDateTime now) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<UnifiedEventDO>()
|
||||||
|
.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<UnifiedEventDO>()
|
||||||
|
.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 时不覆盖既有事件,只由服务层回查幂等结果。
|
||||||
|
*
|
||||||
|
* <p>注意:这里不写 sequence_no 字段,必须由 V16 DDL 的 PostgreSQL sequence/default 分配。</p>
|
||||||
|
*/
|
||||||
|
@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);
|
||||||
|
|
||||||
|
}
|
||||||
@ -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。
|
||||||
|
*
|
||||||
|
* <p>统一事件只保存已经脱敏后的 JSON 摘要;这里用 JDBC OTHER 交给 PostgreSQL JSONB 解析,
|
||||||
|
* 避免把 JSON 字符串再次序列化成字符串字面量。</p>
|
||||||
|
*/
|
||||||
|
public class JsonbStringTypeHandler extends BaseTypeHandler<String> {
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package cn.iocoder.muse.module.events.domain;
|
||||||
|
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events SSE Last-Event-ID 游标。
|
||||||
|
*
|
||||||
|
* <p>游标解析必须 fail-closed:非法输入只返回安全错误码,不能把原始 cursor 或解析异常透出到浏览器。</p>
|
||||||
|
*/
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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 脱敏器。
|
||||||
|
*
|
||||||
|
* <p>source owner 可能来自外部模型、知识库或支付/授权链路;这里递归检查 key 与 value,
|
||||||
|
* 发现授权头、bearer token、secret 或 provider raw body 后,事件会被拒绝且只保存脱敏摘要。</p>
|
||||||
|
*/
|
||||||
|
@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<String, Object> payloadSummary) {
|
||||||
|
Map<String, Object> 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<String, Object> 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<Object> sanitizedList = new ArrayList<>(collection.size());
|
||||||
|
collection.forEach(item -> sanitizedList.add(sanitizeValue(item, detection)));
|
||||||
|
return sanitizedList;
|
||||||
|
}
|
||||||
|
if (value instanceof Object[] array) {
|
||||||
|
List<Object> 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<String, Object> parseSanitizedJson(String json) {
|
||||||
|
Map<String, Object> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<UnifiedEventDO> 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.<Long>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<UnifiedEventDO> 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<UnifiedEventDO> 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<UnifiedEventDO> 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<Callable<EventsPublishRespDTO>> 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<EventsPublishRespDTO> 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<Long> 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<Integer> 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<String, Object> 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<String, Object> 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<UnifiedEventDO> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<UnifiedEventDO> 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<String, Object> 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<Map<String, Object>> sentMaps(SseEmitter emitter) {
|
||||||
|
List<Map<String, Object>> maps = new ArrayList<>();
|
||||||
|
for (Object data : sentData(emitter)) {
|
||||||
|
if (data instanceof Map<?, ?> map) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> casted = (Map<String, Object>) 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<Object> 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 <T> Future<T> submit(Callable<T> task) {
|
||||||
|
throw new UnsupportedOperationException("Events stream 测试只需要 Runnable 提交路径");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class RecordingFuture implements Future<Object> {
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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 根目录");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
19
muse-cloud/muse-module-events/pom.xml
Normal file
19
muse-cloud/muse-module-events/pom.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<parent>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</parent>
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<modules>
|
||||||
|
<module>muse-module-events-api</module>
|
||||||
|
<module>muse-module-events-server</module>
|
||||||
|
</modules>
|
||||||
|
<artifactId>muse-module-events</artifactId>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<name>${project.artifactId}</name>
|
||||||
|
<description>events 模块,负责统一事件流和跨 owner 事件发布契约。</description>
|
||||||
|
</project>
|
||||||
@ -59,6 +59,11 @@
|
|||||||
<artifactId>muse-module-meta-server</artifactId>
|
<artifactId>muse-module-meta-server</artifactId>
|
||||||
<version>${revision}</version>
|
<version>${revision}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>muse-module-events-server</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.iocoder.cloud</groupId>
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
<artifactId>muse-module-knowledge-server</artifactId>
|
<artifactId>muse-module-knowledge-server</artifactId>
|
||||||
|
|||||||
@ -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 迁移验收。
|
||||||
|
*
|
||||||
|
* <p>本测试会 clean 指定数据库的 public schema,因此只允许连接真实库名以 {@code _test} 结尾的隔离库。
|
||||||
|
* 数据库密码只能从环境变量读取,避免 Surefire XML 或命令历史记录泄露凭据。</p>
|
||||||
|
*/
|
||||||
|
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<String> REQUIRED_INDEXES = List.of(
|
||||||
|
"idx_muse_unified_event_owner_sequence"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final List<String> 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<String> REQUIRED_TRIGGERS = List.of(
|
||||||
|
"trg_muse_unified_event_updated_at"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final Set<String> 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://<host>/prod?<query-redacted>"),
|
||||||
|
"拒绝信息必须只展示真实 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://<host>:5432/muse_local_p1r7a_test?<query-redacted>", 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 '<user-redacted>'");
|
||||||
|
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<String, Boolean> indexChecks = indexChecks(connection);
|
||||||
|
assertFalse(indexChecks.containsValue(false), "Events 关键索引必须全部存在: " + indexChecks);
|
||||||
|
|
||||||
|
Map<String, Boolean> constraintChecks = constraintChecks(connection);
|
||||||
|
assertFalse(constraintChecks.containsValue(false), "Events 关键约束必须全部存在: " + constraintChecks);
|
||||||
|
|
||||||
|
Map<String, Boolean> 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<String, Boolean> indexChecks(Connection connection) throws SQLException {
|
||||||
|
Map<String, Boolean> 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<String, Boolean> constraintChecks(Connection connection) throws SQLException {
|
||||||
|
Map<String, Boolean> 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<String, Boolean> triggerChecks(Connection connection) throws SQLException {
|
||||||
|
Map<String, Boolean> 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<String> 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<String> columns = new ArrayList<>();
|
||||||
|
while (resultSet.next()) {
|
||||||
|
columns.add(resultSet.getString(1));
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> 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<String> 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 ? "" : "?<query-redacted>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskJdbcHost(String urlPart) {
|
||||||
|
return urlPart.replaceAll("//([^:/?#]+)", "//<host>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskUser(String user) {
|
||||||
|
return user == null || user.isBlank() ? "<user-redacted>" : "<user-redacted>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeSql(String sql) {
|
||||||
|
return sql.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<String> extractNames(String normalizedSql, Pattern pattern) {
|
||||||
|
Matcher matcher = pattern.matcher(normalizedSql);
|
||||||
|
return matcher.results()
|
||||||
|
.map(result -> result.group(1))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertNoDuplicateNames(List<String> names, String message) {
|
||||||
|
Set<String> uniqueNames = new HashSet<>(names);
|
||||||
|
assertEquals(uniqueNames.size(), names.size(), message + ": " + names);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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 路由归属静态门禁。
|
||||||
|
*
|
||||||
|
* <p>该测试只证明统一事件流路由已迁移到 Events owner 骨架,
|
||||||
|
* 不验证真实 SSE replay、heartbeat、error、cursor 语义。</p>
|
||||||
|
*/
|
||||||
|
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<Path, Boolean> 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<String, Path> 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("<artifactId>" + forbiddenArtifactId + "</artifactId>"),
|
||||||
|
"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("<artifactId>muse-module-events-server</artifactId>"),
|
||||||
|
"muse-server 必须装配 muse-module-events-server");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<Path, Boolean> collectStreamEventsRouteOwners() throws IOException {
|
||||||
|
Map<Path, Boolean> owners = new LinkedHashMap<>();
|
||||||
|
Path root = findRepositoryRoot().resolve(MUSE_CLOUD_ROOT);
|
||||||
|
|
||||||
|
try (Stream<Path> 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<String, Path> 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<String, Path> 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<String> 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<String, Boolean> methods = new LinkedHashMap<>();
|
||||||
|
while (matcher.find()) {
|
||||||
|
methods.put(matcher.group(1), true);
|
||||||
|
}
|
||||||
|
yield methods.isEmpty() ? Set.of() : methods.keySet();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> extractMappingPaths(String mappingBody) {
|
||||||
|
if (mappingBody == null || mappingBody.isBlank()) {
|
||||||
|
return Set.of("");
|
||||||
|
}
|
||||||
|
Matcher matcher = Pattern.compile("\"([^\"]+)\"").matcher(mappingBody);
|
||||||
|
Map<String, Boolean> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@
|
|||||||
<module>muse-module-infra</module>
|
<module>muse-module-infra</module>
|
||||||
<module>muse-module-content</module>
|
<module>muse-module-content</module>
|
||||||
<module>muse-module-meta</module>
|
<module>muse-module-meta</module>
|
||||||
|
<module>muse-module-events</module>
|
||||||
<module>muse-module-knowledge</module>
|
<module>muse-module-knowledge</module>
|
||||||
<module>muse-module-market</module>
|
<module>muse-module-market</module>
|
||||||
<module>muse-module-member</module>
|
<module>muse-module-member</module>
|
||||||
|
|||||||
52
muse-cloud/sql/muse/V16__extend_events_sse_schema.sql
Normal file
52
muse-cloud/sql/muse/V16__extend_events_sse_schema.sql
Normal file
@ -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();
|
||||||
Loading…
x
Reference in New Issue
Block a user